# Caching Results

> How to answer repeated calls to a slow FrontMCP tool from a cache with @frontmcp/plugin-cache, whose calls share an entry, what makes two calls the same, how long an entry lives, and what not to cache.

Source: https://frontmcp.dev/learn/caching-results

A model asks the same question more than once. It looks up the customer on a ticket, then again before it writes the reply, then again for the next ticket from the same customer. When the lookup waits on a slow CRM, every repeat makes the user wait again for an answer the model already had. `@frontmcp/plugin-cache` stores what a tool returns and answers the next call with the same arguments from the store, without running the tool. Turning it on is one line in the app and one field on the tool. Deciding what to cache, and for whom, is the rest of this lesson.

**You will learn**
- How to cache a tool's results with the cache plugin
- Why the cache keeps each caller's answers apart, and when to share them
- What makes two calls the same call
- How long an entry lives, and why you can't clear one
- What not to cache

## A lookup the model repeats

`get_customer` asks the CRM for a customer's account. The CRM takes a fifth of a second to answer, and `crm.ts` counts the requests it gets. Open the **Tests** tab:

```ts get-customer.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { crm } from "./crm";

@Tool({
  name: "get_customer",
  description: "Get a customer account by its id: the company's name and its plan.",
  inputSchema: { id: z.string().describe("Customer id, like C-1") },
  annotations: { readOnlyHint: true },
})
export class GetCustomer extends ToolContext {
  async execute({ id }: { id: string }) {
    return crm.getCustomer(id);
  }
}
```

```ts crm.ts
import { PublicMcpError } from "@frontmcp/sdk";

// Stands in for a CRM that is slow to answer, and counts the requests it gets.
const customers: Record<string, { id: string; name: string; plan: string }> = {
  "C-1": { id: "C-1", name: "Acme Corp", plan: "enterprise" },
  "C-2": { id: "C-2", name: "Globex", plan: "starter" },
};

export const crm = {
  requests: 0,
  async getCustomer(id: string) {
    crm.requests++;
    await new Promise((resolve) => setTimeout(resolve, 200));
    const customer = customers[id];
    if (!customer) throw new PublicMcpError(`There's no customer ${id}.`);
    return { ...customer };
  },
};
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { GetCustomer } from "./get-customer.tool";

@App({ id: "help-desk", name: "Help Desk", tools: [GetCustomer] })
class HelpDeskApp {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
})
export default class Server {}
```

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

test("🚩 every call waits for the CRM again", async ({ mcp }) => {
  const before = crm.requests;
  for (let i = 0; i < 3; i++) {
    const result = await mcp.tools.call("get_customer", { id: "C-1" });
    expect(result.json()).toEqual({ id: "C-1", name: "Acme Corp", plan: "enterprise" });
    expect(result.durationMs).toBeGreaterThan(150);
  }
  expect(crm.requests - before).toBe(3);
});
```

Three calls, three requests to the CRM, three waits, for the same answer. A customer's name and plan don't change from one minute to the next, so the second and third calls could have been answered at once.

## Turning the cache on

Install the plugin:

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

Then register it on the app with `CachePlugin.init({ type: "memory" })`, and mark each tool it should cache with `cache`. `ttl` is how long an answer is kept, in seconds:

```ts main.ts active
import { App, FrontMcp } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";
import { GetCustomer } from "./get-customer.tool";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetCustomer],
  plugins: [CachePlugin.init({ type: "memory" })],
})
class HelpDeskApp {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
})
export default class Server {}
```

```ts get-customer.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { crm } from "./crm";

@Tool({
  name: "get_customer",
  description: "Get a customer account by its id: the company's name and its plan.",
  inputSchema: { id: z.string().describe("Customer id, like C-1") },
  annotations: { readOnlyHint: true },
  cache: { ttl: 300 }, // keep each answer for 5 minutes
})
export class GetCustomer extends ToolContext {
  async execute({ id }: { id: string }) {
    return crm.getCustomer(id);
  }
}
```

```ts crm.ts
import { PublicMcpError } from "@frontmcp/sdk";

// Stands in for a CRM that is slow to answer, and counts the requests it gets.
const customers: Record<string, { id: string; name: string; plan: string }> = {
  "C-1": { id: "C-1", name: "Acme Corp", plan: "enterprise" },
  "C-2": { id: "C-2", name: "Globex", plan: "starter" },
};

export const crm = {
  requests: 0,
  async getCustomer(id: string) {
    crm.requests++;
    await new Promise((resolve) => setTimeout(resolve, 200));
    const customer = customers[id];
    if (!customer) throw new PublicMcpError(`There's no customer ${id}.`);
    return { ...customer };
  },
};
```

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

test("🚩 an anonymous caller is never answered from the cache", async ({ mcp }) => {
  const before = crm.requests;
  await mcp.tools.call("get_customer", { id: "C-1" });
  const second = await mcp.tools.call("get_customer", { id: "C-1" });
  expect(crm.requests - before).toBe(2);
  expect(second.raw._meta?.cache).toBeUndefined();
});
```

The cache is on, and the CRM still gets every request. Nothing is wrong with the setup. By default the cache keeps a separate entry for each caller, so that one user is never given an answer that was meant for another. The Playground's client doesn't sign in, and under MCP 2026-07-28 a caller without credentials is a [new anonymous caller](https://frontmcp.dev/learn/authenticating-clients#a-server-anyone-can-call) on every request. Each call stores an entry under a caller that never comes back, and no call ever reads one. There's no error or warning.

## Sharing answers between callers

A customer's account looks the same to every support agent who asks, so there's no reason to keep one copy per caller. `keyByIdentity: false` makes every caller share one entry per tool and arguments:

```ts main.ts active
import { App, FrontMcp } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";
import { GetCustomer } from "./get-customer.tool";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetCustomer],
  plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })], // ✅ the same answer for everyone
})
class HelpDeskApp {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
})
export default class Server {}
```

```ts get-customer.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { crm } from "./crm";

@Tool({
  name: "get_customer",
  description: "Get a customer account by its id: the company's name and its plan.",
  inputSchema: { id: z.string().describe("Customer id, like C-1") },
  annotations: { readOnlyHint: true },
  cache: { ttl: 300 },
})
export class GetCustomer extends ToolContext {
  async execute({ id }: { id: string }) {
    return crm.getCustomer(id);
  }
}
```

```ts crm.ts
import { PublicMcpError } from "@frontmcp/sdk";

// Stands in for a CRM that is slow to answer, and counts the requests it gets.
const customers: Record<string, { id: string; name: string; plan: string }> = {
  "C-1": { id: "C-1", name: "Acme Corp", plan: "enterprise" },
  "C-2": { id: "C-2", name: "Globex", plan: "starter" },
};

export const crm = {
  requests: 0,
  async getCustomer(id: string) {
    crm.requests++;
    await new Promise((resolve) => setTimeout(resolve, 200));
    const customer = customers[id];
    if (!customer) throw new PublicMcpError(`There's no customer ${id}.`);
    return { ...customer };
  },
};
```

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

test("the second call is answered from the cache, at once", async ({ mcp }) => {
  const before = crm.requests;
  const first = await mcp.tools.call("get_customer", { id: "C-2" });
  const second = await mcp.tools.call("get_customer", { id: "C-2" });
  expect(crm.requests - before).toBe(1);
  expect(second.durationMs).toBeLessThan(150);
  expect(first.raw._meta?.cache).toBeUndefined();
  expect(second.raw._meta?.cache).toBe("hit");
});

test("the data is what the tool returned, marker aside", async ({ mcp }) => {
  await mcp.tools.call("get_customer", { id: "C-1" });
  const hit = await mcp.tools.call("get_customer", { id: "C-1" });
  expect(hit.raw._meta?.cache).toBe("hit");
  expect(hit.json()).toEqual({ id: "C-1", name: "Acme Corp", plan: "enterprise" });
});

test("a failed call isn't stored", async ({ mcp }) => {
  const before = crm.requests;
  expect(await mcp.tools.call("get_customer", { id: "C-9" })).toBeError();
  expect(await mcp.tools.call("get_customer", { id: "C-9" })).toBeError();
  expect(crm.requests - before).toBe(2);
});
```

Now the first call asks the CRM and the second is answered from the store, without running `execute()`. Every caller shares the entry, so one agent's lookup also saves the next agent's wait.

- **A hit is marked, and its data isn't changed.** Its result carries `_meta.cache: "hit"`, next to the data the tool returned. The marker isn't in `structuredContent` or in the text the model reads ([What a cache hit looks like](https://frontmcp.dev/reference/plugins/cache#what-a-cache-hit-looks-like)).
- **Only calls that succeed are stored.** A tool that throws stores nothing, so `C-9` is looked up again each time.
- **The store is in memory.** `type: "memory"` keeps entries in the server's process: they're lost on restart and not shared between instances. With more than one instance, keep them in Redis instead ([Using Redis](https://frontmcp.dev/reference/plugins/cache#using-redis)).

## Answers that depend on who's asking

Sharing is right for a customer's account. It's wrong for `my_open_tickets`, which lists the tickets assigned to the caller. A plugin applies to the tools of the app it's registered on, so tools that need different rules go in different apps, each with its own `CachePlugin.init()`. `my_open_tickets` keeps the default, one entry per caller.

The Playground's client is anonymous, which would tell us nothing here, so the tests call the server in-process with [`createDirect()`](https://frontmcp.dev/learn/running-frontmcp-anywhere) as two signed-in support agents, `nour` and `sam`:

```ts main.ts active
import { App, FrontMcp } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";
import { GetCustomer, MyOpenTickets } from "./tools";

@App({
  id: "customers",
  name: "Customers",
  tools: [GetCustomer],
  plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })], // the same answer for everyone
})
class CustomersApp {}

@App({
  id: "queue",
  name: "Queue",
  tools: [MyOpenTickets],
  plugins: [CachePlugin.init({ type: "memory" })], // ✅ one entry per caller
})
class QueueApp {}

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [CustomersApp, QueueApp],
};

@FrontMcp(config)
export default class Server {}
```

```ts tools.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { crm, queue } from "./stores";

@Tool({
  name: "get_customer",
  description: "Get a customer account by its id: the company's name and its plan.",
  inputSchema: { id: z.string().describe("Customer id, like C-1") },
  annotations: { readOnlyHint: true },
  cache: { ttl: 300 },
})
export class GetCustomer extends ToolContext {
  async execute({ id }: { id: string }) {
    return crm.getCustomer(id);
  }
}

@Tool({
  name: "my_open_tickets",
  description: "The open support tickets assigned to you.",
  inputSchema: {},
  annotations: { readOnlyHint: true },
  cache: { ttl: 60 },
})
export class MyOpenTickets extends ToolContext {
  async execute() {
    const agent = this.auth.user.sub;
    return { agent, tickets: await queue.openTicketsFor(agent) };
  }
}
```

```ts stores.ts
import { PublicMcpError } from "@frontmcp/sdk";

// Stand in for the CRM and the ticket database, and count the requests they get.
const customers: Record<string, { id: string; name: string; plan: string }> = {
  "C-1": { id: "C-1", name: "Acme Corp", plan: "enterprise" },
  "C-2": { id: "C-2", name: "Globex", plan: "starter" },
};

export const crm = {
  requests: 0,
  async getCustomer(id: string) {
    crm.requests++;
    await new Promise((resolve) => setTimeout(resolve, 200));
    const customer = customers[id];
    if (!customer) throw new PublicMcpError(`There's no customer ${id}.`);
    return { ...customer };
  },
};

const assigned: Record<string, string[]> = { nour: ["T-1", "T-3"], sam: ["T-2"] };

export const queue = {
  searches: 0,
  async openTicketsFor(agent: string) {
    queue.searches++;
    return assigned[agent] ?? [];
  },
};
```

```ts per-caller.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";
import { config } from "./main";
import { queue } from "./stores";
import { MyOpenTickets } from "./tools";

const nour = { authContext: { user: { sub: "nour" } } };
const sam = { authContext: { user: { sub: "sam" } } };

test("each agent gets their own tickets, and their repeats from the cache", async () => {
  const server = await FrontMcpInstance.createDirect(config);
  const before = queue.searches;
  expect((await server.callTool("my_open_tickets", {}, nour)).structuredContent).toEqual({ agent: "nour", tickets: ["T-1", "T-3"] });
  expect((await server.callTool("my_open_tickets", {}, sam)).structuredContent).toEqual({ agent: "sam", tickets: ["T-2"] });

  const again = await server.callTool("my_open_tickets", {}, nour);
  expect(again._meta?.cache).toBe("hit");
  expect(queue.searches - before).toBe(2); // Nour's second call didn't search
  await server.dispose();
});

test("🚩 with keyByIdentity: false, Sam is given Nour's tickets", async () => {
  @App({ id: "queue", name: "Queue", tools: [MyOpenTickets], plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })] })
  class SharedQueueApp {}

  const server = await FrontMcpInstance.createDirect({ ...config, apps: [SharedQueueApp] });
  await server.callTool("my_open_tickets", {}, nour);
  const forSam = await server.callTool("my_open_tickets", {}, sam);
  expect(forSam._meta?.cache).toBe("hit");
  expect(forSam.structuredContent).toMatchObject({ agent: "nour", tickets: ["T-1", "T-3"] });
  await server.dispose();
});
```

Each agent's first call searches, and their repeat is answered from their own entry. With the whole app on `keyByIdentity: false`, the second test shows what goes wrong: Sam asks for his tickets and gets Nour's, marked as a cache hit.

With `keyByIdentity` on, the cache tells callers apart by what [authentication](https://frontmcp.dev/learn/authenticating-clients) established:

| Caller | Entries |
| --- | --- |
| Signed in through an identity provider (`transparent`, `local` or `remote`) | One set per user, by the token's `sub`, shared by all of that user's sessions and devices |
| A `static` key | One set per key |
| Anonymous, under MCP 2026-07-28 | None that are ever read back: a new caller on every request |
| Anonymous, on a session-based client (before 2026-07-28) | One set per session |

> **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 a tool reads `this.auth`, or its answer depends on the caller's tenant, account or permissions, the next caller gets the first caller's data. Share only answers that are the same for everyone, and put those tools in an app of their own.

## What makes two calls the same

The cache looks an answer up by a key made of three things: the caller (unless `keyByIdentity` is `false`), the tool as `<app id>:<tool name>`, and the call's arguments **after** the input schema has validated them. That last part decides how often a model's calls hit. `search_articles` searches the help center, whose articles are the same for everyone:

```ts search-articles.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { helpCenter } from "./help-center";

@Tool({
  name: "search_articles",
  description: "Search the help center's articles. Returns the best matches, most relevant first.",
  inputSchema: {
    query: z.string().trim().toLowerCase().describe("What to search for, like 'password reset'"),
    limit: z.number().int().min(1).max(10).default(5).describe("How many articles to return"),
  },
  annotations: { readOnlyHint: true },
  cache: { ttl: 600 },
})
export class SearchArticles extends ToolContext {
  async execute({ query, limit }: { query: string; limit: number }) {
    return { articles: await helpCenter.search(query, limit) };
  }
}
```

```ts help-center.ts
// Stands in for the help center's search, and counts the searches it runs.
const articles = [
  { slug: "reset-your-password", title: "Reset your password" },
  { slug: "password-rules", title: "Password rules for your team" },
  { slug: "export-tickets", title: "Export tickets as CSV" },
];

export const helpCenter = {
  searches: 0,
  async search(query: string, limit: number) {
    helpCenter.searches++;
    const words = query.split(/\s+/);
    return articles.filter((a) => words.some((w) => a.title.toLowerCase().includes(w))).slice(0, limit);
  },
};
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";
import { SearchArticles } from "./search-articles.tool";

@App({
  id: "help-center",
  name: "Help Center",
  tools: [SearchArticles],
  plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })],
})
class HelpCenterApp {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpCenterApp],
})
export default class Server {}
```

```ts key.test.ts
import { test, expect } from "@frontmcp/testing";
import { helpCenter } from "./help-center";

/** How many of these calls reached the help center. */
async function searchesFor(mcp: any, ...calls: object[]) {
  const before = helpCenter.searches;
  for (const args of calls) await mcp.tools.call("search_articles", args);
  return helpCenter.searches - before;
}

test("the order of the arguments doesn't matter", async ({ mcp }) => {
  expect(await searchesFor(mcp, { query: "export", limit: 3 }, { limit: 3, query: "export" })).toBe(1);
});

test("the schema's transforms run first", async ({ mcp }) => {
  expect(await searchesFor(mcp, { query: "rules" }, { query: "  Rules " }, { query: "RULES" })).toBe(1);
});

test("a default counts as if the model had sent it", async ({ mcp }) => {
  expect(await searchesFor(mcp, { query: "team" }, { query: "team", limit: 5 })).toBe(1);
});

test("arguments the schema doesn't declare don't count", async ({ mcp }) => {
  expect(await searchesFor(mcp, { query: "csv" }, { query: "csv", locale: "fr" })).toBe(1);
});

test("any other value is another entry", async ({ mcp }) => {
  expect(await searchesFor(mcp, { query: "password", limit: 1 }, { query: "password", limit: 2 })).toBe(2);
});
```

The key is built from what `execute()` receives, not from what the model sent. Object keys are sorted, arguments the schema doesn't declare are dropped, defaults are filled in, and transforms like `trim()` and `toLowerCase()` have already run. So a schema that normalizes its input also makes the cache hit more often: `"  Rules "` and `"RULES"` are the same search as `"rules"`. Anything left that differs, even `limit: 1` against `limit: 2`, is another entry.

## How long an entry lives

An entry lives for its `ttl`, and then the next call runs the tool again. The plugin has no way to delete an entry sooner. Here `update_plan` changes a customer's plan in the CRM, and `get_customer` keeps giving the old one. The tests move the clock forward by replacing `Date.now()`, which is what the cache's memory store reads:

```ts tools.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { crm } from "./crm";

@Tool({
  name: "get_customer",
  description: "Get a customer account by its id: the company's name and its plan. May be up to 5 minutes old.",
  inputSchema: { id: z.string().describe("Customer id, like C-1") },
  annotations: { readOnlyHint: true },
  cache: { ttl: 300 },
})
export class GetCustomer extends ToolContext {
  async execute({ id }: { id: string }) {
    return crm.getCustomer(id);
  }
}

@Tool({
  name: "update_plan",
  description: "Change a customer's plan.",
  inputSchema: { id: z.string(), plan: z.enum(["starter", "business", "enterprise"]) },
})
export class UpdatePlan extends ToolContext {
  async execute({ id, plan }: { id: string; plan: "starter" | "business" | "enterprise" }) {
    return crm.setPlan(id, plan);
  }
}

@Tool({
  name: "get_sla_policy",
  description: "The help desk's response times for each plan.",
  inputSchema: {},
  annotations: { readOnlyHint: true },
  cache: true, // 🚩 kept for a day: the plugin's defaultTTL
})
export class GetSlaPolicy extends ToolContext {
  async execute() {
    crm.requests++;
    return { starter: "2 days", business: "8 hours", enterprise: "1 hour" };
  }
}
```

```ts crm.ts
import { PublicMcpError } from "@frontmcp/sdk";

// Stands in for a CRM that is slow to answer, and counts the requests it gets.
const customers: Record<string, { id: string; name: string; plan: string }> = {
  "C-1": { id: "C-1", name: "Acme Corp", plan: "enterprise" },
  "C-2": { id: "C-2", name: "Globex", plan: "starter" },
};

export const crm = {
  requests: 0,
  async getCustomer(id: string) {
    crm.requests++;
    await new Promise((resolve) => setTimeout(resolve, 200));
    const customer = customers[id];
    if (!customer) throw new PublicMcpError(`There's no customer ${id}.`);
    return { ...customer };
  },
  async setPlan(id: string, plan: string) {
    const customer = customers[id];
    if (!customer) throw new PublicMcpError(`There's no customer ${id}.`);
    customer.plan = plan;
    return { ...customer };
  },
};
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";
import { GetCustomer, GetSlaPolicy, UpdatePlan } from "./tools";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetCustomer, UpdatePlan, GetSlaPolicy],
  plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })],
})
class HelpDeskApp {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
})
export default class Server {}
```

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

/** Runs `fn` with the clock `seconds` ahead. */
async function inSeconds<T>(seconds: number, fn: () => Promise<T>) {
  const realNow = Date.now;
  const now = realNow();
  Date.now = () => now + seconds * 1000;
  try {
    return await fn();
  } finally {
    Date.now = realNow;
  }
}

test("🚩 a change doesn't reach the cached answer", async ({ mcp }) => {
  await mcp.tools.call("get_customer", { id: "C-2" }); // stored: starter
  await mcp.tools.call("update_plan", { id: "C-2", plan: "business" });
  const cached = await mcp.tools.call("get_customer", { id: "C-2" });
  expect(cached.raw._meta?.cache).toBe("hit");
  expect(cached.json().plan).toBe("starter");
});

test("once the ttl has passed, the tool runs again", async ({ mcp }) => {
  const fresh = await inSeconds(301, () => mcp.tools.call("get_customer", { id: "C-2" }));
  expect(fresh.raw._meta?.cache).toBeUndefined();
  expect(fresh.json().plan).toBe("business");
});

test("cache: true keeps the first answer for a day, however often it's read", async ({ mcp }) => {
  const before = crm.requests;
  const day = 24 * 60 * 60;
  await mcp.tools.call("get_sla_policy", {});
  const lastMinute = await inSeconds(day - 60, () => mcp.tools.call("get_sla_policy", {}));
  expect(lastMinute.raw._meta?.cache).toBe("hit");
  const nextDay = await inSeconds(day + 60, () => mcp.tools.call("get_sla_policy", {}));
  expect(nextDay.raw._meta?.cache).toBeUndefined();
  expect(crm.requests - before).toBe(2);
});
```

The model changed the plan, and the next lookup still says `starter`: the model is told two different things in a row. A client can skip the cache for one call by sending the header `x-frontmcp-disable-cache: true`, but that call's fresh answer isn't stored, and the old entry stays for everyone else ([Letting a client skip the cache](https://frontmcp.dev/reference/plugins/cache#letting-a-client-skip-the-cache)). Short of restarting a server whose store is in memory, which empties it, nothing clears an entry early.

So choose `ttl` as the oldest answer you're willing to give:

- **Say it in the description.** "May be up to 5 minutes old" lets the model tell the user, or tell them a change can take a few minutes to show.
- **Keep it short, or don't cache, for data your own tools change.** If `update_plan` is used often, a five-minute-old plan will confuse the model more than a slow lookup.
- **Give every cached tool an explicit `ttl`.** `cache: true` uses the plugin's `defaultTTL`, a day unless you set it, so a change can take a day to reach the model. The last test shows `get_sla_policy` answering from its first call until the day is over, however often it's read. (Before FrontMCP 1.9, each read gave the entry a full day again, so an answer read daily was never refreshed. Reading an entry now keeps it alive only with `slideWindow: true`: see [the reference](https://frontmcp.dev/reference/plugins/cache#cache-in-tool).)

## What not to cache

A cached call doesn't run `execute()`. For a tool that only reads, that's the point. For a tool that does something, it means the second call doesn't do it:

```ts add-note.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

export const notes: string[] = [];

@Tool({
  name: "add_note",
  description: "Add an internal note to a support ticket.",
  inputSchema: { ticketId: z.string(), text: z.string() },
  cache: { ttl: 300 }, // 🚩 on a tool that changes something
})
export class AddNote extends ToolContext {
  async execute({ ticketId, text }: { ticketId: string; text: string }) {
    notes.push(`${ticketId}: ${text}`);
    return { ticketId, added: true };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";
import { AddNote } from "./add-note.tool";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [AddNote],
  plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })],
})
class HelpDeskApp {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
})
export default class Server {}
```

```ts notes.test.ts
import { test, expect } from "@frontmcp/testing";
import { notes } from "./add-note.tool";

test("🚩 the second note is never added, and the model is told it was", async ({ mcp }) => {
  const before = notes.length;
  const args = { ticketId: "T-2", text: "Customer tried again, still failing." };
  await mcp.tools.call("add_note", args);
  const second = await mcp.tools.call("add_note", args);
  expect(second.json()).toMatchObject({ ticketId: "T-2", added: true });
  expect(notes.length - before).toBe(1);
});
```

An agent really may add the same note twice, after the customer writes in again. The second call answers "added" from the cache, and nothing is added. Leave these out of the cache:

| Don't cache | Because |
| --- | --- |
| Tools that change something: `add_note`, `close_ticket`, `refund_invoice` | A hit skips the change and reports success. Cache only tools that just read, the ones you'd mark `readOnlyHint: true`. |
| Answers that depend on the caller, in an app with `keyByIdentity: false` | The first caller's answer goes to everyone. Keep them per caller, behind authentication. |
| Answers the model acts on right away | A ticket's status just before closing it, a balance just before refunding it: a few minutes old can be wrong. |

## Recap

- `@frontmcp/plugin-cache` answers a repeated call from a store without running `execute()`. Register `CachePlugin.init({ type: "memory" })` on the app, and mark each tool with `cache: { ttl }`, in seconds.
- By default, each caller has their own entries. Anonymous callers under MCP 2026-07-28 are new on every request, so they're never answered from the cache.
- `keyByIdentity: false` shares entries between callers. Use it only for answers that are the same for everyone, in an app of their own; a plugin covers the tools of the app it's registered on.
- Two calls are the same when the tool and the validated arguments match: key order, undeclared arguments and defaults don't matter, and the schema's transforms have run.
- An entry lives until its `ttl` ends, and the plugin can't clear it sooner. Choose `ttl` as the oldest answer you'll accept, and say so in the description.
- Cache only tools that read. Failed calls aren't stored, whether the tool throws or returns an error result.
- Every option, the Redis stores and the bypass header are in the [Cache plugin reference](https://frontmcp.dev/reference/plugins/cache).

## Try some challenges

Each challenge runs hidden checks against your code. Edit the code, then press **Check**.

### Challenge: Cache the article lookup
`get_article` reads an article from the help center, which is slow, and the model reads the same few articles over and over. Answer repeated lookups from a cache for 10 minutes. Articles are the same for everyone who asks.

```ts main.ts active
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
import { helpCenter } from "./help-center";

@Tool({
  name: "get_article",
  description: "Read a help center article by its slug.",
  inputSchema: { slug: z.string().describe("The article's slug, like reset-your-password") },
  annotations: { readOnlyHint: true },
})
class GetArticle extends ToolContext {
  async execute({ slug }: { slug: string }) {
    return helpCenter.getArticle(slug);
  }
}

@App({ id: "help-center", name: "Help Center", tools: [GetArticle] })
class HelpCenterApp {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpCenterApp],
})
export default class Server {}
```

```ts main.ts solution
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";
import { helpCenter } from "./help-center";

@Tool({
  name: "get_article",
  description: "Read a help center article by its slug. May be up to 10 minutes old.",
  inputSchema: { slug: z.string().describe("The article's slug, like reset-your-password") },
  annotations: { readOnlyHint: true },
  cache: { ttl: 600 },
})
class GetArticle extends ToolContext {
  async execute({ slug }: { slug: string }) {
    return helpCenter.getArticle(slug);
  }
}

@App({
  id: "help-center",
  name: "Help Center",
  tools: [GetArticle],
  plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })],
})
class HelpCenterApp {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpCenterApp],
})
export default class Server {}
```

```ts help-center.ts
import { PublicMcpError } from "@frontmcp/sdk";

// Stands in for a slow help center, and counts the requests it gets.
const articles: Record<string, { slug: string; title: string; body: string }> = {
  "reset-your-password": { slug: "reset-your-password", title: "Reset your password", body: "Open the sign-in page and choose Forgot password." },
  "export-tickets": { slug: "export-tickets", title: "Export tickets as CSV", body: "Open Reports, then choose Export." },
};

export const helpCenter = {
  requests: 0,
  async getArticle(slug: string) {
    helpCenter.requests++;
    await new Promise((resolve) => setTimeout(resolve, 200));
    const article = articles[slug];
    if (!article) throw new PublicMcpError(`There's no article ${slug}.`);
    return { ...article };
  },
};
```

```ts article.test.ts hidden
import { test, expect } from "@frontmcp/testing";
import { helpCenter } from "./help-center";

test("a repeated lookup doesn't reach the help center", async ({ mcp }) => {
  const before = helpCenter.requests;
  const first = await mcp.tools.call("get_article", { slug: "export-tickets" });
  const second = await mcp.tools.call("get_article", { slug: "export-tickets" });
  expect(second.json().title).toBe(first.json().title);
  expect(helpCenter.requests - before).toBe(1);
});

test("the repeat is marked as a cache hit", async ({ mcp }) => {
  await mcp.tools.call("get_article", { slug: "reset-your-password" });
  const second = await mcp.tools.call("get_article", { slug: "reset-your-password" });
  expect(second.raw._meta?.cache).toBe("hit");
});

test("each article is looked up for itself", async ({ mcp }) => {
  expect((await mcp.tools.call("get_article", { slug: "export-tickets" })).json().title).toBe("Export tickets as CSV");
  expect((await mcp.tools.call("get_article", { slug: "reset-your-password" })).json().title).toBe("Reset your password");
});

test("a missing article is still an error", async ({ mcp }) => {
  expect(await mcp.tools.call("get_article", { slug: "no-such-article" })).toBeError();
});
```

**Hint:**
It takes a change to the app and one to the tool. Then think about who the Playground's caller is: the checks call the tool the way the Playground does, without signing in.

**Solution:**
`CachePlugin.init({ type: "memory", keyByIdentity: false })` on the app and `cache: { ttl: 600 }` on the tool turn the cache on for `get_article`. `keyByIdentity: false` is what makes it work for the Playground's caller: with the default, each anonymous request is a new caller with its own entries, so nothing is ever read back. Sharing is safe because an article doesn't depend on who reads it. The missing article throws, so it isn't stored, and the description now says how old an article may be.

### Challenge: Keep each agent's queue their own
The help desk caches everything in one app with `keyByIdentity: false`. That's right for `get_article`, and wrong for `my_open_tickets`: Sam gets whichever agent's tickets were cached first. Keep each agent's tickets their own, still answered from the cache when an agent asks again, and keep articles shared.

```ts main.ts active
import { App, FrontMcp } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";
import { GetArticle, MyOpenTickets } from "./tools";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetArticle, MyOpenTickets],
  plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })],
})
class HelpDeskApp {}

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
};

@FrontMcp(config)
export default class Server {}
```

```ts main.ts solution
import { App, FrontMcp } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";
import { GetArticle, MyOpenTickets } from "./tools";

@App({
  id: "help-center",
  name: "Help Center",
  tools: [GetArticle],
  plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })], // the same for everyone
})
class HelpCenterApp {}

@App({
  id: "queue",
  name: "Queue",
  tools: [MyOpenTickets],
  plugins: [CachePlugin.init({ type: "memory" })], // one entry per caller
})
class QueueApp {}

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpCenterApp, QueueApp],
};

@FrontMcp(config)
export default class Server {}
```

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

export const counts = { articleReads: 0, ticketSearches: 0 };
const assigned: Record<string, string[]> = { nour: ["T-1", "T-3"], sam: ["T-2"] };

@Tool({
  name: "get_article",
  description: "Read a help center article by its slug.",
  inputSchema: { slug: z.string() },
  annotations: { readOnlyHint: true },
  cache: { ttl: 600 },
})
export class GetArticle extends ToolContext {
  async execute({ slug }: { slug: string }) {
    counts.articleReads++;
    return { slug, title: slug === "export-tickets" ? "Export tickets as CSV" : "Reset your password" };
  }
}

@Tool({
  name: "my_open_tickets",
  description: "The open support tickets assigned to you.",
  inputSchema: {},
  annotations: { readOnlyHint: true },
  cache: { ttl: 60 },
})
export class MyOpenTickets extends ToolContext {
  async execute() {
    counts.ticketSearches++;
    const agent = this.auth.user.sub;
    return { agent, tickets: assigned[agent] ?? [] };
  }
}
```

```ts queue.test.ts hidden
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";
import { counts } from "./tools";

const nour = { authContext: { user: { sub: "nour" } } };
const sam = { authContext: { user: { sub: "sam" } } };

test("each agent gets their own open tickets", async () => {
  const server = await FrontMcpInstance.createDirect(config);
  expect((await server.callTool("my_open_tickets", {}, nour)).structuredContent).toEqual({ agent: "nour", tickets: ["T-1", "T-3"] });
  expect((await server.callTool("my_open_tickets", {}, sam)).structuredContent).toEqual({ agent: "sam", tickets: ["T-2"] });
  await server.dispose();
});

test("an agent's repeat is answered from the cache", async () => {
  const server = await FrontMcpInstance.createDirect(config);
  await server.callTool("my_open_tickets", {}, sam);
  const before = counts.ticketSearches;
  const again = await server.callTool("my_open_tickets", {}, sam);
  expect(again._meta?.cache).toBe("hit");
  expect(counts.ticketSearches).toBe(before);
  await server.dispose();
});

test("articles are still shared between callers", async ({ mcp }) => {
  await mcp.tools.call("get_article", { slug: "reset-your-password" });
  const before = counts.articleReads;
  const second = await mcp.tools.call("get_article", { slug: "reset-your-password" });
  expect(second.raw._meta?.cache).toBe("hit");
  expect(counts.articleReads).toBe(before);
});
```

**Hint:**
`keyByIdentity` is an option of the plugin, not of a tool, and a plugin registered on an app applies to that app's tools only.

**Solution:**
`get_article` and `my_open_tickets` need different rules, so they go in different apps, each with its own `CachePlugin.init()`: the help center app shares its entries with `keyByIdentity: false`, and the queue app keeps the default, one entry per caller. The checks call `my_open_tickets` as `nour` and `sam`, who each get their own tickets, and whose repeats are still hits. Removing `cache` from `my_open_tickets` would also keep the tickets apart, but every call would search again.

### Challenge: Refresh the SLA policy every hour
`get_sla_policy` is cached with `cache: true`, so a change to the help desk's response times can take a day to reach the model. Make the policy at most an hour old, and keep answering repeats within the hour from the cache.

```ts get-sla-policy.tool.ts active
import { Tool, ToolContext } from "@frontmcp/sdk";
import { policies } from "./policies";

@Tool({
  name: "get_sla_policy",
  description: "The help desk's response times for each plan.",
  inputSchema: {},
  annotations: { readOnlyHint: true },
  cache: true,
})
export class GetSlaPolicy extends ToolContext {
  async execute() {
    return policies.read();
  }
}
```

```ts get-sla-policy.tool.ts solution
import { Tool, ToolContext } from "@frontmcp/sdk";
import { policies } from "./policies";

@Tool({
  name: "get_sla_policy",
  description: "The help desk's response times for each plan. May be up to an hour old.",
  inputSchema: {},
  annotations: { readOnlyHint: true },
  cache: { ttl: 3600 },
})
export class GetSlaPolicy extends ToolContext {
  async execute() {
    return policies.read();
  }
}
```

```ts policies.ts
// Stands in for the help desk's settings, and counts the reads.
export const policies = {
  reads: 0,
  current: { starter: "2 days", business: "8 hours", enterprise: "1 hour" },
  async read() {
    policies.reads++;
    return { ...policies.current };
  },
};
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";
import { GetSlaPolicy } from "./get-sla-policy.tool";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetSlaPolicy],
  plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })],
})
class HelpDeskApp {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
})
export default class Server {}
```

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

async function inSeconds<T>(seconds: number, fn: () => Promise<T>) {
  const realNow = Date.now;
  const now = realNow();
  Date.now = () => now + seconds * 1000;
  try {
    return await fn();
  } finally {
    Date.now = realNow;
  }
}

test("a repeat within the hour is answered from the cache", async ({ mcp }) => {
  await mcp.tools.call("get_sla_policy", {});
  const before = policies.reads;
  const again = await inSeconds(30 * 60, () => mcp.tools.call("get_sla_policy", {}));
  expect(again.raw._meta?.cache).toBe("hit");
  expect(policies.reads).toBe(before);
});

test("a change reaches the model within the hour", async ({ mcp }) => {
  await mcp.tools.call("get_sla_policy", {});
  policies.current = { ...policies.current, business: "4 hours" };
  const later = await inSeconds(60 * 60 + 60, () => mcp.tools.call("get_sla_policy", {}));
  expect(later.json().business).toBe("4 hours");
});
```

**Hint:**
`cache: true` takes the plugin's `defaultTTL`, which is a day unless you set it. A tool can say how long its own entries live.

**Solution:**
`cache: { ttl: 3600 }` keeps each answer for an hour from the call that stored it, however often it's read in between, so the change reaches the model on the first call after the hour. The description says so, which lets the model tell the user that a change can take an hour to show. Setting `defaultTTL: 3600` on the plugin passes the checks too, but changes every `cache: true` tool in the app.
