# Cache plugin

> @frontmcp/plugin-cache answers repeated tool calls from a store instead of running the tool again. Every option, what the key is made of, who gets which entry, and what it caches and doesn't.

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

`@frontmcp/plugin-cache` stores what a tool returns and answers the next call with the same arguments from the store, without running `execute()`. Tools opt in one by one, with `cache` in `@Tool`, or by name, with `toolPatterns`. Each entry belongs to the caller who made it, so a signed-in user never gets another user's cached answer. The flip side surprises people: on a server without authentication, a client using MCP 2026-07-28 is a new stranger on every request, so it never gets a cached answer until you say the answer is the same for everyone, with `keyByIdentity: false`. [Caching Results](https://frontmcp.dev/learn/caching-results) teaches it step by step.

```ts
plugins: [CachePlugin.init({ type: "memory", defaultTTL?, toolPatterns?, bypassHeader?, keyByIdentity? })]

@Tool({ name, inputSchema, cache: true | { ttl?, slideWindow? } })
```

---

## Reference

### `CachePlugin.init(options)`

Install the package, then register the plugin on the app whose tools it should cache, and mark those tools with `cache`:

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

```ts rates.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";

@Tool({
  name: "get_exchange_rate",
  description: "The current exchange rate between two currencies",
  inputSchema: { from: z.string().length(3), to: z.string().length(3) },
  cache: { ttl: 300 }, // answer repeated calls from the cache for 5 minutes
})
export class GetExchangeRate extends ToolContext {
  async execute({ from, to }: { from: string; to: string }) {
    return { from, to, rate: await lookUpRate(from, to) };
  }
}

@App({
  id: "rates",
  name: "Rates",
  tools: [GetExchangeRate],
  plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })], // the same rate for every caller
})
export class RatesApp {}
```

[See more examples below.](#usage) The plugin is also exported by `@frontmcp/plugins`, which bundles the cache, Remember, CodeCall and dashboard plugins; see the [plugins overview](https://frontmcp.dev/reference/plugins).

#### Options

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `type` | `"memory"`, `"redis"`, `"redis-client"` or `"global-store"` | `"memory"` | Where entries are kept. See [Stores](#stores). Required by the types. |
| `defaultTTL` | `number` (seconds) | `86400` (a day) | How long an entry lives when the tool's `cache` doesn't give a `ttl`, and for tools matched by `toolPatterns`. |
| `keyByIdentity` | `boolean` | `true` | Keep a separate entry per caller. Set it to `false` only for tools whose answer is the same for everyone. See [Who gets which entry](#who-gets-which-entry). |
| `toolPatterns` | `string[]` | `[]` | Cache these tools too, without `cache` in their metadata. Each pattern is a tool name or `<app id>:<tool name>`, and `*` matches anything: `"catalog:*"`, `"get_*"`. A tool with `cache: { ttl: 0 }` stays out: see [`cache` in `@Tool`](#cache-in-tool). |
| `bypassHeader` | `string` | `"x-frontmcp-disable-cache"` | A request header that makes the plugin skip the cache for that call. It must start with `x-frontmcp-`: `init()` throws a `CachePluginConfigurationError` for any other name. See [Letting a client skip the cache](#letting-a-client-skip-the-cache). |
| `config` | `{ host, port, password?, db? }` | | With `type: "redis"`: where to connect. Passed to `ioredis` as given, so other `ioredis` options work too. |
| `client` | `Redis` (ioredis) | | With `type: "redis-client"`: a client you created. |

`plugins: [CachePlugin]`, without `init()`, and `CachePlugin.init()`, with no argument, are the same as `type: "memory"` with the defaults. To build the options from providers, pass `init({ inject, useFactory })`; the factory may be `async`.

#### Stores

| `type` | Where entries live |
| --- | --- |
| `"memory"` | A `Map` in the server's process, one for each server. Entries are lost on restart and aren't shared between instances. |
| `"redis"` | A Redis server the plugin connects to with `config`. |
| `"redis-client"` | Your own `ioredis` client, in `client`. |
| `"global-store"` | The store in `@FrontMcp({ redis })`: Redis, or Vercel KV with `redis: { provider: "vercel-kv", url?, token? }`. Without `redis`, the server doesn't start. |

Only `"memory"` runs in the Playground, which has no network. The Redis and Vercel KV stores are described from the package's source and aren't shown on this page:

- Keys are SHA-256 hashes (see [What the key is made of](#what-the-key-is-made-of)), and values are JSON. Entries expire in the store after their TTL.
- With `"global-store"` and Redis, the plugin opens its own connection with `host`, `port`, `password` and `db` from `redis`. `tls` and `keyPrefix` aren't passed on.
- Vercel KV needs the `@vercel/kv` package, and there's no `type: "vercel-kv"`: use `"global-store"`. The plugin builds its own client from `redis.url` and `redis.token`, or, without them, `KV_REST_API_URL` and `KV_REST_API_TOKEN`, on the first call that uses the cache, and tries again on the next call if that fails. Keys start with `redis.keyPrefix`, or `cache:`.

### `cache` in `@Tool`

The plugin adds a `cache` field to `@Tool` (and `tool()`) metadata. A tool is cached when it has `cache`, or its name matches `toolPatterns`.

| Value | An entry lives | Reading the entry |
| --- | --- | --- |
| `cache: true` | `defaultTTL` | Leaves its expiry as it was. |
| `cache: { ttl: 60 }` | 60 seconds | Leaves its expiry as it was. |
| `cache: { ttl: 60, slideWindow: true }` | 60 seconds | Gives it a full 60 seconds again. |
| `cache: { slideWindow: true }` | `defaultTTL` | Gives it a full `defaultTTL` again. |
| Matched by `toolPatterns` only | `defaultTTL` | Leaves its expiry as it was. |
| `cache: { ttl: 0 }`, or a negative `ttl` | No entry | The tool isn't cached, even when `toolPatterns` matches it. |

Only `slideWindow: true` keeps an entry alive while it's read. [Choosing how long entries live](#choosing-how-long-entries-live) shows each one, and [Turning the cache off for one tool](#turning-the-cache-off-for-one-tool) the last row.

Changed in 1.9: `cache: true` gave an entry a full `defaultTTL` again each time it was read, so an answer read at least once a day was never refreshed, and `slideWindow` did nothing without a `ttl`.

### How a call is answered

The plugin hooks `execute` of every tool in the app it's registered on (every app's, on `@FrontMcp`):

1. **Before `execute()`** (`Will("execute")`, priority 1000): if the tool is cached and the request doesn't carry the bypass header, the plugin builds the key and looks it up. If there's an entry (one that still matches the tool's `outputSchema`), it answers with it: `execute()` doesn't run, and neither do `Did("execute")` hooks, the plugin's own or other plugins'. `Did("finalize")` hooks still run. See [Counting calls the cache answers](#counting-calls-the-cache-answers). The hit's marker is added to the result's `_meta` through [`ctx.state.resultMeta`](https://frontmcp.dev/reference/sdk/hooks#adding-to-the-results-_meta), so your own hooks can do the same.
2. **After `execute()`** (`Did("execute")`, priority 1000): the plugin stores what `execute()` returned, as JSON, for the tool's TTL.

#### What the key is made of

The key is a SHA-256 hash of three things:

- **The caller**, unless `keyByIdentity` is `false`. See the next section.
- **The tool**, as `<app id>:<tool name>`.
- **The arguments**, after validation, with object keys sorted: `{ from: "EUR", to: "USD" }` and `{ to: "USD", from: "EUR" }` are the same entry. Arguments the tool's `inputSchema` doesn't declare are dropped by validation, so they don't count.

#### Who gets which entry

With `keyByIdentity: true` (the default), the caller part of the key is:

| Caller | Caller part | Result |
| --- | --- | --- |
| A signed-in user, with `transparent`, `local` or `remote` [auth](https://frontmcp.dev/reference/auth/modes) | The token's `sub` | One entry per user, shared by all of that user's sessions |
| A `static` key | `static:` and a hash of the key | One entry per key |
| Anonymous, on a `public` server, with MCP 2026-07-28 | `anon:` and a new id for every request | A new key for every call: **never served from the cache** |
| Anonymous, with a session-based client (before 2026-07-28) | `anon:` and an id that lasts for the session | One entry per session |

With `keyByIdentity: false`, every caller shares one entry per tool and arguments. The Playground sends 2026-07-28 requests only; the session-based row was checked on FrontMCP's Node HTTP server.

> **Pitfall: Without authentication, 2026-07-28 clients never get a cached answer**
Under MCP 2026-07-28, FrontMCP gives an anonymous caller a new identity on every request, so with the default `keyByIdentity: true`, a `public` server stores an entry on every call and never reads one back. There's no error or warning. Session-based clients are cached, but only within their session. For tools whose answer doesn't depend on who asks (exchange rates, a product catalog, public documents), set `keyByIdentity: false`. For anything else, turn on [authentication](https://frontmcp.dev/reference/auth/modes): the cache can only keep answers apart for callers it can tell apart.

> **Pitfall: keyByIdentity: false gives one caller's answer to everyone**
With `keyByIdentity: false`, the first caller's result is served to every later caller with the same arguments, until it expires. If the tool reads `this.auth`, a tenant, an account or permissions, the next user gets the first user's data. Keep `keyByIdentity` on for those tools, and put tools that are safe to share in their own app with their own `CachePlugin.init({ keyByIdentity: false })`: the plugin applies to its app's tools only. [Caching per caller](#caching-per-caller) shows both.

#### What is stored

- **What `execute()` returns**, before it's turned into `content`, as JSON.
- **Only calls that succeed.** A tool that throws, calls [`this.fail()`](https://frontmcp.dev/reference/sdk/fail), or returns a result with `isError: true` stores nothing, so the next call runs again. See [Keeping errors out of the cache](#keeping-errors-out-of-the-cache).

#### What the client sees on a hit

A result from the cache has `_meta.cache: "hit"` in the result's `_meta`. Its `structuredContent` and text are what `execute()` returned, unchanged, so the model reads the same data on a hit as on a miss. A miss has no `cache` in `_meta`. See [What a cache hit looks like](#what-a-cache-hit-looks-like).

> **Note**
Changed in 1.8.6: before, the plugin also added `_meta: { cache: "hit" }` to a plain-object result, so the marker was in `structuredContent` and in the text the model read (unless an `outputSchema` removed it), and a result that wasn't an object had no marker at all. Now the data is always the tool's own, and every hit is marked in the result's `_meta`.

#### Invalidation

Entries go away only when they expire. The plugin has no API to delete an entry, and the bypass header doesn't replace one: a call with the header runs the tool and leaves the old entry in place. To make a change visible sooner, use a shorter `ttl`, or restart the server (memory) or delete the keys (Redis).

#### Caveats

- The plugin caches **tools** only, not resources or prompts.
- On `@App({ plugins })` it caches that app's tools; on `@FrontMcp({ plugins })`, every app's. See [where you register a plugin](https://frontmcp.dev/reference/sdk/plugin#where-you-register-a-plugin).
- The memory store belongs to the server: two servers built from the same app in one process each have their own entries. (Before 1.8.6 they shared one.)
- A `type` the plugin doesn't know, like `"vercel-kv"`, silently uses a memory store.

---

## Usage

### Caching a tool whose answer is the same for everyone

An exchange rate is the same whoever asks, so the cache is shared: `keyByIdentity: false`. The tool counts its lookups, so the tests can see which calls ran it:

```ts rates.app.ts active
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";

let lookups = 0; // how many times the rate was really looked up

@Tool({
  name: "get_exchange_rate",
  description: "The current exchange rate between two currencies",
  inputSchema: { from: z.string().length(3), to: z.string().length(3) },
  cache: { ttl: 300 },
})
export class GetExchangeRate extends ToolContext {
  async execute({ from, to }: { from: string; to: string }) {
    lookups += 1;
    return { from, to, rate: from === to ? 1 : 1.0842, lookup: lookups };
  }
}

@App({
  id: "rates",
  name: "Rates",
  tools: [GetExchangeRate],
  plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })],
})
export class RatesApp {}
```

```ts rates.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance, Tool, ToolContext, tool } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";
import { RatesApp } from "./rates.app";

test("the same arguments are answered from the cache", async ({ mcp }) => {
  const first = await mcp.tools.call("get_exchange_rate", { from: "EUR", to: "GBP" });
  const second = await mcp.tools.call("get_exchange_rate", { from: "EUR", to: "GBP" });
  expect(second.json().lookup).toBe(first.json().lookup); // execute() didn't run again
  expect(first.raw._meta?.cache).toBeUndefined();
  expect(second.raw._meta?.cache).toBe("hit");
});

test("the order of the arguments doesn't matter", async ({ mcp }) => {
  const first = await mcp.tools.call("get_exchange_rate", { from: "JPY", to: "USD" });
  const second = await mcp.tools.call("get_exchange_rate", { to: "USD", from: "JPY" });
  expect(second.json().lookup).toBe(first.json().lookup);
});

test("arguments the schema doesn't declare don't count", async ({ mcp }) => {
  const first = await mcp.tools.call("get_exchange_rate", { from: "SEK", to: "USD" });
  const second = await mcp.tools.call("get_exchange_rate", { from: "SEK", to: "USD", note: "for the invoice" });
  expect(second.json().lookup).toBe(first.json().lookup);
});

test("other arguments are another entry", async ({ mcp }) => {
  const eur = await mcp.tools.call("get_exchange_rate", { from: "EUR", to: "CHF" });
  const usd = await mcp.tools.call("get_exchange_rate", { from: "USD", to: "CHF" });
  expect(usd.json().lookup).toBe(eur.json().lookup + 1);
});

test("each server has a store of its own", async () => {
  const config = { info: { name: "rates", version: "1.0.0" }, apps: [RatesApp] };
  const first = await FrontMcpInstance.createDirect(config);
  const second = await FrontMcpInstance.createDirect(config);
  const lookupIn = async (server: typeof first) =>
    ((await server.callTool("get_exchange_rate", { from: "NOK", to: "USD" })).structuredContent as { lookup: number }).lookup;
  const firstLookup = await lookupIn(first);
  expect(await lookupIn(first)).toBe(firstLookup);
  expect(await lookupIn(second)).toBe(firstLookup + 1);
  await first.dispose();
  await second.dispose();
});

test("`cache` works on tool() functions, and not inside `metadata`", async () => {
  let runs = 0;
  const getFee = tool({ name: "get_card_fee", description: "The card fee", inputSchema: {}, cache: true })(() => ({ run: ++runs }));

  @Tool({
    name: "get_wire_fee",
    description: "The wire transfer fee",
    inputSchema: {},
    // @ts-expect-error `metadata` isn't a @Tool option
    metadata: { cache: true },
  })
  class GetWireFee extends ToolContext {
    async execute() {
      return { run: ++runs };
    }
  }

  @App({ id: "fees", name: "Fees", tools: [getFee, GetWireFee], plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })] })
  class FeesApp {}

  const server = await FrontMcpInstance.createDirect({ info: { name: "fees", version: "1.0.0" }, apps: [FeesApp] });
  const runOf = async (name: string) => ((await server.callTool(name, {})).structuredContent as { run: number }).run;
  const card = await runOf("get_card_fee");
  expect(await runOf("get_card_fee")).toBe(card); // from the cache
  const wire = await runOf("get_wire_fee");
  expect(await runOf("get_wire_fee")).toBe(wire + 1); // 🚩 ran again
  await server.dispose();
});
```

Remove `keyByIdentity: false` and the first test fails: the Playground's server has no authentication, so each call comes from a new anonymous caller with its own key.

### Caching per caller

Most tools answer differently for different users, like a list of the caller's own tickets. Keep `keyByIdentity` on, and give the server authentication so callers can be told apart. Here `server.ts` uses two [static keys](https://frontmcp.dev/reference/auth/modes), one per agent; 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 tickets.app.ts active
import { App, Tool, ToolContext } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";

let searches = 0;

@Tool({ name: "my_open_tickets", description: "The caller's open tickets", inputSchema: {}, cache: { ttl: 60 } })
export class MyOpenTickets extends ToolContext {
  async execute() {
    searches += 1;
    return { assignee: this.auth.user.sub, tickets: [`T-${searches}`], search: searches };
  }
}

// ✅ One entry per caller (the default)
@App({ id: "tickets", name: "Tickets", tools: [MyOpenTickets], plugins: [CachePlugin.init({ type: "memory" })] })
export class TicketsApp {}
```

```ts server.ts
import { App } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";
import { MyOpenTickets, TicketsApp } from "./tickets.app";

// 🚩 The same tool, with one entry for everyone
@App({
  id: "tickets",
  name: "Tickets",
  tools: [MyOpenTickets],
  plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })],
})
class SharedTicketsApp {}

const info = { name: "help-desk", version: "1.0.0" };
const auth = { mode: "static" as const, tokens: ["agent-nour-key", "agent-sam-key"] };

// In your project: @FrontMcp(perCaller) export default class Server {}
export const perCaller = { info, apps: [TicketsApp], auth };
export const shared = { info, apps: [SharedTicketsApp], auth };
```

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

/** Starts a server from this config and calls a tool 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, 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: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
        }),
      }),
    );
    return (await response.json()).result;
  };
}
```

```ts per-caller.test.ts
import { test, expect } from "@frontmcp/testing";
import { App } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";
import { start } from "./call";
import { perCaller, shared } from "./server";
import { MyOpenTickets } from "./tickets.app";

test("each caller gets their own entry", async () => {
  const call = await start(perCaller);
  const nour = await call("my_open_tickets", "agent-nour-key");
  const sam = await call("my_open_tickets", "agent-sam-key");
  expect(sam.structuredContent.assignee).not.toBe(nour.structuredContent.assignee);
  expect(sam.structuredContent.search).toBe(nour.structuredContent.search + 1); // Sam's call ran the tool

  const nourAgain = await call("my_open_tickets", "agent-nour-key");
  expect(nourAgain._meta.cache).toBe("hit");
  expect(nourAgain.structuredContent.assignee).toBe(nour.structuredContent.assignee);
});

test("🚩 keyByIdentity: false serves Nour's tickets to Sam", async () => {
  const call = await start(shared);
  const nour = await call("my_open_tickets", "agent-nour-key");
  const sam = await call("my_open_tickets", "agent-sam-key");
  expect(sam._meta.cache).toBe("hit");
  expect(sam.structuredContent.assignee).toBe(nour.structuredContent.assignee);
});

test("🚩 anonymous 2026-07-28 callers are never served from the cache", async ({ mcp }) => {
  // The Playground's own server has no authentication
  const first = await mcp.tools.call("my_open_tickets", {});
  const second = await mcp.tools.call("my_open_tickets", {});
  expect(second.json().search).toBe(first.json().search + 1);
  expect(second.raw._meta?.cache).toBeUndefined();
});

test("`CachePlugin.init()` with no argument, and the bare class, keep an entry per caller too", async () => {
  for (const plugin of [CachePlugin.init(), CachePlugin]) {
    @App({ id: "tickets", name: "Tickets", tools: [MyOpenTickets], plugins: [plugin] })
    class WithDefaults {}

    const call = await start({ ...perCaller, apps: [WithDefaults] });
    await call("my_open_tickets", "agent-nour-key");
    expect((await call("my_open_tickets", "agent-nour-key"))._meta.cache).toBe("hit");
    expect((await call("my_open_tickets", "agent-sam-key"))._meta.cache).toBeUndefined();
  }
});

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

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

The same holds for users who sign in with `transparent`, `local` or `remote` auth: the key uses the token's `sub`, so a user's entry is shared between their sessions and devices, and no one else's.

### Choosing how long entries live

`ttl` is in seconds. Whether reading an entry keeps it alive depends on how `cache` is written. The tests move the clock forward by replacing `Date.now()`, which is what the memory store reads:

```ts market.app.ts active
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";

let runs = 0;
const input = { symbol: z.string() };

@Tool({ name: "get_quote", description: "A delayed stock quote", inputSchema: input, cache: { ttl: 60 } })
export class GetQuote extends ToolContext {
  async execute({ symbol }: { symbol: string }) {
    return { symbol, price: 101.5, run: ++runs };
  }
}

@Tool({
  name: "get_trending",
  description: "Trending symbols in a sector",
  inputSchema: input,
  cache: { ttl: 60, slideWindow: true },
})
export class GetTrending extends ToolContext {
  async execute({ symbol }: { symbol: string }) {
    return { sector: symbol, symbols: ["ACME", "GLOBEX"], run: ++runs };
  }
}

@Tool({ name: "get_company", description: "A company's profile", inputSchema: input, cache: true })
export class GetCompany extends ToolContext {
  async execute({ symbol }: { symbol: string }) {
    return { symbol, name: "Acme Corp", run: ++runs };
  }
}

@Tool({ name: "get_sector", description: "A company's sector", inputSchema: input, cache: { slideWindow: true } })
export class GetSector extends ToolContext {
  async execute({ symbol }: { symbol: string }) {
    return { symbol, sector: "Industrials", run: ++runs };
  }
}

@App({
  id: "market",
  name: "Market",
  tools: [GetQuote, GetTrending, GetCompany, GetSector],
  plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false, defaultTTL: 120 })],
})
export class MarketApp {}
```

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

/** Calls a tool at 0s, 50s, 100s and 170s, and returns the run number each call got. */
async function runsOver(mcp: any, tool: string, symbol: string) {
  const realNow = Date.now;
  const start = realNow();
  const runs: number[] = [];
  try {
    for (const seconds of [0, 50, 100, 170]) {
      Date.now = () => start + seconds * 1000;
      runs.push((await mcp.tools.call(tool, { symbol })).json().run);
    }
  } finally {
    Date.now = realNow;
  }
  return runs.map((run) => run - runs[0]); // 0 = the first run's result
}

test("{ ttl: 60 } expires 60 seconds after it was stored, even while it's read", async ({ mcp }) => {
  expect(await runsOver(mcp, "get_quote", "Q1")).toEqual([0, 0, 1, 2]);
});

test("{ ttl: 60, slideWindow: true } lives while it's read at least every 60 seconds", async ({ mcp }) => {
  expect(await runsOver(mcp, "get_trending", "T1")).toEqual([0, 0, 0, 1]);
});

test("cache: true expires defaultTTL after it was stored, even while it's read", async ({ mcp }) => {
  expect(await runsOver(mcp, "get_company", "C1")).toEqual([0, 0, 0, 1]);
});

test("{ slideWindow: true } without a ttl slides by defaultTTL", async ({ mcp }) => {
  expect(await runsOver(mcp, "get_sector", "S1")).toEqual([0, 0, 0, 0]);
});
```

`get_quote` runs again at 100 seconds because its entry from 0 seconds expired at 60, although it was read at 50; the new entry expires at 160, so 170 runs it again too. `get_trending`'s entry is pushed back each time it's read, until the gap between reads is more than 60 seconds. `get_company` has no `ttl`, so it lives `defaultTTL` (120 seconds) from when it was stored, and 170 runs it again. `get_sector` slides like `get_trending`, by `defaultTTL`.

### Caching tools by name

`toolPatterns` caches tools that don't have `cache` in their metadata, such as tools from a [remote server](https://frontmcp.dev/reference/server/remote) or an [OpenAPI adapter](https://frontmcp.dev/reference/adapters/openapi). A pattern is compared with the tool's name and with `<app id>:<tool name>`, so `"catalog:*"` takes a whole app. Here the plugin is registered on the server, and caches the catalog app and the orders app's `track_parcel`:

```ts main.ts active
import { FrontMcp } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";
import { CatalogApp, OrdersApp } from "./apps";

@FrontMcp({
  info: { name: "shop", version: "1.0.0" },
  apps: [CatalogApp, OrdersApp],
  plugins: [
    CachePlugin.init({ type: "memory", keyByIdentity: false, defaultTTL: 600, toolPatterns: ["catalog:*", "track_*"] }),
  ],
})
export default class Server {}
```

```ts apps.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";

let runs = 0;

@Tool({ name: "get_product", description: "A product by SKU", inputSchema: { sku: z.string() } })
export class GetProduct extends ToolContext {
  async execute({ sku }: { sku: string }) {
    return { sku, name: "Mechanical keyboard", run: ++runs };
  }
}

@Tool({ name: "get_order", description: "An order by id", inputSchema: { id: z.string() } })
export class GetOrder extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, status: "shipped", run: ++runs };
  }
}

@App({ id: "catalog", name: "Catalog", tools: [GetProduct] })
export class CatalogApp {}

@Tool({ name: "track_parcel", description: "Where a parcel is", inputSchema: { id: z.string() } })
export class TrackParcel extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, at: "Rotterdam", run: ++runs };
  }
}

@App({ id: "orders", name: "Orders", tools: [GetOrder, TrackParcel] })
export class OrdersApp {}
```

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

test("the catalog app's tools are cached", async ({ mcp }) => {
  const first = await mcp.tools.call("get_product", { sku: "MS-7" });
  expect((await mcp.tools.call("get_product", { sku: "MS-7" })).json().run).toBe(first.json().run);
});

test("so is a tool whose name matches", async ({ mcp }) => {
  const first = await mcp.tools.call("track_parcel", { id: "P-1" });
  expect((await mcp.tools.call("track_parcel", { id: "P-1" })).json().run).toBe(first.json().run);
});

test("other tools aren't", async ({ mcp }) => {
  const first = await mcp.tools.call("get_order", { id: "O-1" });
  expect((await mcp.tools.call("get_order", { id: "O-1" })).json().run).toBe(first.json().run + 1);
});
```

A tool that matches a pattern and also has `cache` uses its own `ttl`.

### Letting a client skip the cache

A request with `x-frontmcp-disable-cache: true` (or `1`) runs the tool and returns a fresh result. It doesn't store that result: the entry already in the cache stays, and the next call without the header gets it.

```ts rates.app.ts active
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";

let lookups = 0;

@Tool({
  name: "get_exchange_rate",
  description: "The current exchange rate between two currencies",
  inputSchema: { from: z.string().length(3), to: z.string().length(3) },
  cache: { ttl: 300 },
})
export class GetExchangeRate extends ToolContext {
  async execute({ from, to }: { from: string; to: string }) {
    return { from, to, rate: 1.0842, lookup: ++lookups };
  }
}

@App({
  id: "rates",
  name: "Rates",
  tools: [GetExchangeRate],
  plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })],
})
export class RatesApp {}
```

```ts bypass.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";
import { GetExchangeRate, RatesApp } from "./rates.app";

/** The same tool, with the bypass header renamed. */
function withBypassHeader(bypassHeader: string) {
  @App({
    id: "rates",
    name: "Rates",
    tools: [GetExchangeRate],
    plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false, bypassHeader })],
  })
  class Rates {}
  return Rates;
}

async function start(app: object = RatesApp) {
  const server = await FrontMcpInstance.createFetchHandler({ info: { name: "rates", version: "1.0.0" }, apps: [app as never] });
  return async (headers: Record<string, string> = {}) => {
    const response = await server(
      new Request("https://rates.example.com/", {
        method: "POST",
        headers: {
          "content-type": "application/json",
          "mcp-protocol-version": "2026-07-28",
          "mcp-method": "tools/call",
          "mcp-name": "get_exchange_rate",
          ...headers,
        },
        body: JSON.stringify({
          jsonrpc: "2.0",
          id: 1,
          method: "tools/call",
          params: {
            name: "get_exchange_rate",
            arguments: { from: "EUR", to: "USD" },
            _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" },
          },
        }),
      }),
    );
    return (await response.json()).result.structuredContent.lookup;
  };
}

test("the header runs the tool, and leaves the cached entry as it was", async () => {
  const call = await start();
  const cached = await call();
  expect(await call()).toBe(cached);
  expect(await call({ "x-frontmcp-disable-cache": "true" })).toBe(cached + 1);
  expect(await call({ "x-frontmcp-disable-cache": "1" })).toBe(cached + 2);
  expect(await call()).toBe(cached); // still the first result
});

test("other values don't skip it", async () => {
  const call = await start();
  const cached = await call();
  expect(await call({ "x-frontmcp-disable-cache": "yes" })).toBe(cached);
});

test("a renamed header works when it starts with x-frontmcp-", async () => {
  const call = await start(withBypassHeader("x-frontmcp-fresh"));
  const cached = await call();
  expect(await call({ "x-frontmcp-fresh": "true" })).toBe(cached + 1);
});

test("a renamed header without the prefix stops init()", () => {
  expect(() => withBypassHeader("x-no-cache")).toThrow('CachePlugin bypassHeader "x-no-cache" is never seen by the plugin');
});
```

To rename the header, set `bypassHeader`, keeping the `x-frontmcp-` prefix. FrontMCP passes only `x-frontmcp-*` headers on to plugins, so the plugin refuses a name like `x-no-cache`: `init()` throws a `CachePluginConfigurationError`, and the server doesn't start. Changed in 1.9: such a name was accepted, and the cache was never skipped. The header is up to the client: any caller can send it, and so make the tool run on every call.

### What a cache hit looks like

A hit carries `_meta.cache: "hit"`, and its data is what the tool returned, whatever that was:

```ts catalog.app.ts active
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";

@Tool({ name: "get_product", description: "A product by SKU", inputSchema: { sku: z.string() }, cache: true })
export class GetProduct extends ToolContext {
  async execute({ sku }: { sku: string }) {
    return { sku, name: "Mechanical keyboard" };
  }
}

@Tool({ name: "get_product_name", description: "A product's name by SKU", inputSchema: { sku: z.string() }, cache: true })
export class GetProductName extends ToolContext {
  async execute() {
    return "Mechanical keyboard";
  }
}

@App({
  id: "catalog",
  name: "Catalog",
  tools: [GetProduct, GetProductName],
  plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })],
})
export class CatalogApp {}
```

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

test("a hit is marked in the result's _meta, and a miss isn't", async ({ mcp }) => {
  const miss = await mcp.tools.call("get_product", { sku: "KB-1" });
  const hit = await mcp.tools.call("get_product", { sku: "KB-1" });
  expect(miss.raw._meta.cache).toBeUndefined();
  expect(hit.raw._meta.cache).toBe("hit");
});

test("the data is what the tool returned", async ({ mcp }) => {
  await mcp.tools.call("get_product", { sku: "KB-2" });
  const hit = await mcp.tools.call("get_product", { sku: "KB-2" });
  expect(hit.raw.structuredContent).toEqual({ sku: "KB-2", name: "Mechanical keyboard" });
  expect(hit.json()).toEqual({ sku: "KB-2", name: "Mechanical keyboard" });
});

test("a result that isn't an object is marked too", async ({ mcp }) => {
  await mcp.tools.call("get_product_name", { sku: "KB-3" });
  const hit = await mcp.tools.call("get_product_name", { sku: "KB-3" });
  expect(hit.raw._meta.cache).toBe("hit");
  expect(hit.json()).toEqual({ value: "Mechanical keyboard" });
});
```

The marker is only in `_meta`. The model reads `content` and `structuredContent`, so it can't tell a hit from a miss. A client that wants to know reads `_meta.cache`. A tool that returns a string or a number is wrapped as `{ value: … }`, as on a miss.

### Turning the cache off for one tool

`cache: { ttl: 0 }` turns caching off for a tool, and so does a negative `ttl`. It wins over `toolPatterns`, so a rule that caches every `get_*` tool can leave one out. Here the plugin caches by name, and `get_live_rate` opts out:

```ts rates.app.ts active
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";

let lookups = 0;
const input = { pair: z.string() };

@Tool({ name: "get_rate", description: "The rate from yesterday's close", inputSchema: input })
export class GetRate extends ToolContext {
  async execute({ pair }: { pair: string }) {
    return { pair, rate: 1.0842, lookup: ++lookups };
  }
}

@Tool({ name: "get_live_rate", description: "The rate right now", inputSchema: input, cache: { ttl: 0 } })
export class GetLiveRate extends ToolContext {
  async execute({ pair }: { pair: string }) {
    return { pair, rate: 1.0847, lookup: ++lookups };
  }
}

@Tool({ name: "get_spot_rate", description: "The rate right now, again", inputSchema: input, cache: { ttl: -1 } })
export class GetSpotRate extends ToolContext {
  async execute({ pair }: { pair: string }) {
    return { pair, rate: 1.0847, lookup: ++lookups };
  }
}

@App({
  id: "rates",
  name: "Rates",
  tools: [GetRate, GetLiveRate, GetSpotRate],
  plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false, toolPatterns: ["get_*"] })],
})
export class RatesApp {}
```

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

test("a tool matched by toolPatterns is cached", async ({ mcp }) => {
  const first = await mcp.tools.call("get_rate", { pair: "EURGBP" });
  const second = await mcp.tools.call("get_rate", { pair: "EURGBP" });
  expect(second.json().lookup).toBe(first.json().lookup);
  expect(second.raw._meta.cache).toBe("hit");
});

test("`ttl: 0` runs the tool every time, whatever toolPatterns says", async ({ mcp }) => {
  const first = await mcp.tools.call("get_live_rate", { pair: "EURGBP" });
  const second = await mcp.tools.call("get_live_rate", { pair: "EURGBP" });
  expect(second.json().lookup).toBe(first.json().lookup + 1);
  expect(second.raw._meta.cache).toBeUndefined();
});

test("so does a negative ttl", async ({ mcp }) => {
  const first = await mcp.tools.call("get_spot_rate", { pair: "EURGBP" });
  const second = await mcp.tools.call("get_spot_rate", { pair: "EURGBP" });
  expect(second.json().lookup).toBe(first.json().lookup + 1);
  expect(second.raw._meta.cache).toBeUndefined();
});
```

CodeCall uses it on `codecall:execute` and `codecall:invoke`: a script's result and a tool's answer are never served from the cache, while `codecall:search` and `codecall:describe` are kept for 60 seconds ([CodeCall's caveats](https://frontmcp.dev/reference/plugins/codecall#caveats)).

### Counting calls the cache answers

A hit ends the call before `execute()`, so `Did("execute")` hooks, like a usage counter in [your own plugin](https://frontmcp.dev/reference/sdk/plugin), don't see it. Hook `Did("finalize")` to see every call:

```ts usage.plugin.ts active
import { DynamicPlugin, FlowCtxOf, Plugin, Provider, ToolHook } from "@frontmcp/sdk";

@Provider({ name: "UsageLog" })
export class UsageLog {
  ran: Record<string, number> = {};
  answered: Record<string, number> = {};
}

const add = (counts: Record<string, number>, ctx: FlowCtxOf<"tools:call-tool">) => {
  const name = ctx.state.tool?.metadata.name;
  if (name) counts[name] = (counts[name] ?? 0) + 1;
};

@Plugin({ name: "usage", providers: [UsageLog], exports: [UsageLog] })
export class UsagePlugin extends DynamicPlugin<object> {
  @ToolHook.Did("execute") // 🚩 skipped when the cache answers
  async countRuns(ctx: FlowCtxOf<"tools:call-tool">) {
    add(this.get(UsageLog).ran, ctx);
  }

  @ToolHook.Did("finalize") // ✅ runs for every call
  async countAnswers(ctx: FlowCtxOf<"tools:call-tool">) {
    add(this.get(UsageLog).answered, ctx);
  }
}
```

```ts catalog.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";
import { UsageLog, UsagePlugin } from "./usage.plugin";

@Tool({ name: "get_product", description: "A product by SKU", inputSchema: { sku: z.string() }, cache: true })
export class GetProduct extends ToolContext {
  async execute({ sku }: { sku: string }) {
    return { sku, name: "Mechanical keyboard" };
  }
}

@Tool({ name: "usage_report", description: "How often each tool ran, and was answered", inputSchema: {} })
export class UsageReport extends ToolContext {
  async execute() {
    const { ran, answered } = this.get(UsageLog);
    return { ran: { ...ran }, answered: { ...answered } };
  }
}

@App({
  id: "catalog",
  name: "Catalog",
  tools: [GetProduct, UsageReport],
  plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false }), UsagePlugin],
})
export class CatalogApp {}
```

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

test('a hit skips `Did("execute")` hooks, and not `Did("finalize")`', async ({ mcp }) => {
  await mcp.tools.call("get_product", { sku: "KB-1" });
  await mcp.tools.call("get_product", { sku: "KB-1" });
  const { ran, answered } = (await mcp.tools.call("usage_report", {})).json();
  expect(ran.get_product).toBe(1);
  expect(answered.get_product).toBe(2);
});
```

### Keeping errors out of the cache

A tool that fails by throwing, or with [`this.fail()`](https://frontmcp.dev/reference/sdk/fail), stores nothing, so the next call tries again. So does a tool that returns a result with `isError: true`. Changed in 1.9: such a result was stored like any other, and served until it expired.

```ts billing.app.ts active
import { App, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";

let attempts = 0;

// Throws: nothing is cached, and the next call tries again
@Tool({ name: "get_invoice", description: "An invoice by id", inputSchema: { id: z.string() }, cache: { ttl: 300 } })
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    attempts += 1;
    this.fail(new PublicMcpError(`Invoice ${id} not found (attempt ${attempts})`, "NOT_FOUND"));
  }
}

// Returns an error result: not cached either
@Tool({ name: "get_receipt", description: "A receipt by id", inputSchema: { id: z.string() }, cache: { ttl: 300 } })
export class GetReceipt extends ToolContext {
  async execute({ id }: { id: string }) {
    attempts += 1;
    return { isError: true, content: [{ type: "text", text: `Receipt ${id} not found (attempt ${attempts})` }] } as never;
  }
}

@App({
  id: "billing",
  name: "Billing",
  tools: [GetInvoice, GetReceipt],
  plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })],
})
export class BillingApp {}
```

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

test("a thrown error isn't cached", async ({ mcp }) => {
  const first = await mcp.tools.call("get_invoice", { id: "INV-1" });
  const second = await mcp.tools.call("get_invoice", { id: "INV-1" });
  expect(first).toBeError();
  expect(second).toBeError();
  expect(second.raw._meta?.cache).toBeUndefined();
  expect(second.text()).not.toBe(first.text()); // a new attempt
});

test("a returned error result isn't cached either", async ({ mcp }) => {
  const first = await mcp.tools.call("get_receipt", { id: "R-1" });
  const second = await mcp.tools.call("get_receipt", { id: "R-1" });
  expect(second).toBeError();
  expect(second.raw._meta?.cache).toBeUndefined();
  expect(second.text()).not.toBe(first.text()); // a new attempt
});
```

### Using Redis

In production, and whenever more than one instance serves the same tools, use Redis so every instance reads the same entries and they survive a restart. The Playground has no network, so this isn't run here:

```ts main.ts
import { FrontMcp } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";
import { RatesApp } from "./rates.app";

@FrontMcp({
  info: { name: "rates", version: "1.0.0" },
  apps: [RatesApp],
  // One Redis for the server: sessions, and every plugin that uses "global-store"
  redis: { provider: "redis", host: process.env.REDIS_HOST ?? "localhost", port: 6379, password: process.env.REDIS_PASSWORD },
  plugins: [CachePlugin.init({ type: "global-store", defaultTTL: 600 })],
})
export default class Server {}
```

For a Redis only the cache uses, pass `type: "redis"` with `config: { host, port, password?, db? }`, or `type: "redis-client"` with an `ioredis` client you created.

---

## Troubleshooting

### Every call runs `execute()` again

Check, in this order:

1. **The caller is anonymous.** On a server without authentication, every 2026-07-28 request has a new identity, so nothing is served from the cache, and a session-based client's entries last only for its session. Set `keyByIdentity: false` for tools whose answer is the same for everyone, or turn on authentication. See [Who gets which entry](#who-gets-which-entry).
2. **The tool isn't cached.** It needs `cache` at the top level of `@Tool` (not inside a `metadata` object, which `@Tool` doesn't have), or a name that matches `toolPatterns`.
3. **The plugin is on another app.** `@App({ plugins })` caches that app's tools only.
4. **The arguments differ.** Any difference in a value is another entry (the order of keys isn't).
5. **The request has the bypass header**, `x-frontmcp-disable-cache: true`.
6. **The entry expired.** See [Choosing how long entries live](#choosing-how-long-entries-live).
7. **The tool has `cache: { ttl: 0 }`**, or a negative `ttl`, which turns caching off. See [Turning the cache off for one tool](#turning-the-cache-off-for-one-tool).

### One user sees another user's data

The plugin was set up with `keyByIdentity: false`, so entries are shared. Remove it for any tool whose result depends on the caller. See [the pitfall](#who-gets-which-entry).

### `CachePlugin bypassHeader "…" is never seen by the plugin`

`bypassHeader` doesn't start with `x-frontmcp-`, and FrontMCP gives plugins only headers with that prefix. Rename it, like `x-frontmcp-no-cache`. See [Letting a client skip the cache](#letting-a-client-skip-the-cache).

### The bypass header does nothing

Check the value: only `true` and `1` skip the cache.

### `Plugin "CachePlugin" 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, as in [Using Redis](#using-redis), or use `type: "redis"` with `config`. The server doesn't start until you do.

### `Dynamic require of "events" is not supported`

Your project is an ES module (`"type": "module"` in `package.json`), and the package is older than 1.9.0, whose ES module build couldn't load there. Update every `@frontmcp/*` package to 1.9.0 or later. See [ES module projects](https://frontmcp.dev/reference/plugins#es-module-projects).
