Cache plugin
@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 teaches it step by step.
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:
npm install @frontmcp/plugin-cacheimport { 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. The plugin is also exported by @frontmcp/plugins, which bundles the cache, Remember, CodeCall and dashboard plugins; see the plugins overview.
Options
| Option | Type | Default | Description |
|---|---|---|---|
type | "memory", "redis", "redis-client" or "global-store" | "memory" | Where entries are kept. See 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. |
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. |
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. |
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), and values are JSON. Entries expire in the store after their TTL.
- With
"global-store"and Redis, the plugin opens its own connection withhost,port,passwordanddbfromredis.tlsandkeyPrefixaren't passed on. - Vercel KV needs the
@vercel/kvpackage, and there's notype: "vercel-kv": use"global-store". The plugin builds its own client fromredis.urlandredis.token, or, without them,KV_REST_API_URLandKV_REST_API_TOKEN, on the first call that uses the cache, and tries again on the next call if that fails. Keys start withredis.keyPrefix, orcache:.
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 shows each one, and 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):
- 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'soutputSchema), it answers with it:execute()doesn't run, and neither doDid("execute")hooks, the plugin's own or other plugins'.Did("finalize")hooks still run. See Counting calls the cache answers. The hit's marker is added to the result's_metathroughctx.state.resultMeta, so your own hooks can do the same. - After
execute()(Did("execute"), priority 1000): the plugin stores whatexecute()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
keyByIdentityisfalse. 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'sinputSchemadoesn'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 | 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.
What is stored
- What
execute()returns, before it's turned intocontent, as JSON. - Only calls that succeed. A tool that throws, calls
this.fail(), or returns a result withisError: truestores nothing, so the next call runs again. See 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.
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. - 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
typethe 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:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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, one per agent; the Playground's own server is public, so the tests start server.ts with createFetchHandler() and call it with each key:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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 or an OpenAPI adapter. 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:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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).
Counting calls the cache answers
A hit ends the call before execute(), so Did("execute") hooks, like a usage counter in your own plugin, don't see it. Hook Did("finalize") to see every call:
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);
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Keeping errors out of the cache
A tool that fails by throwing, or with this.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.
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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:
- 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: falsefor tools whose answer is the same for everyone, or turn on authentication. See Who gets which entry. - The tool isn't cached. It needs
cacheat the top level of@Tool(not inside ametadataobject, which@Tooldoesn't have), or a name that matchestoolPatterns. - The plugin is on another app.
@App({ plugins })caches that app's tools only. - The arguments differ. Any difference in a value is another entry (the order of keys isn't).
- The request has the bypass header,
x-frontmcp-disable-cache: true. - The entry expired. See Choosing how long entries live.
- The tool has
cache: { ttl: 0 }, or a negativettl, which turns caching off. See 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.
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.
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, 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.