Skip to main content
RapidDev - Software Development Agency
mcp-tutorial

How to add caching to an MCP server for performance

Add caching to your MCP server to avoid redundant API calls and slow database queries. Use an in-memory Map for simple cases or Redis for multi-instance deployments. Set TTL values per tool, implement cache invalidation on write operations, and return cached results in the standard MCP content format. Caching can reduce response times from seconds to milliseconds.

What you'll learn

  • How to add in-memory caching with TTL to MCP tool handlers
  • How to integrate Redis for distributed caching across server instances
  • How to implement cache invalidation when data changes
  • How to build a cache key strategy for different tool inputs
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced9 min read25-35 minMCP TypeScript SDK v1.x, Node.js 18+, optional Redis 7+March 2026RapidDev Engineering Team
TL;DR

Add caching to your MCP server to avoid redundant API calls and slow database queries. Use an in-memory Map for simple cases or Redis for multi-instance deployments. Set TTL values per tool, implement cache invalidation on write operations, and return cached results in the standard MCP content format. Caching can reduce response times from seconds to milliseconds.

Speeding Up MCP Servers with Caching Layers

MCP tools often call APIs, query databases, or read large files — operations that are slow and expensive to repeat. This tutorial shows how to add caching at the tool handler level, first with a simple in-memory TTL cache, then with Redis for production deployments. You will learn to generate consistent cache keys from tool inputs, set appropriate TTL values, and invalidate stale entries when underlying data changes.

Prerequisites

  • A working MCP server with at least one tool
  • Node.js 18+ and npm installed
  • Basic understanding of caching concepts (TTL, invalidation)
  • Optional: Redis installed locally or a Redis cloud instance

Step-by-step guide

1

Build an in-memory cache class with TTL support

Create a generic cache class that stores key-value pairs with expiration timestamps. The get method checks if an entry exists and has not expired. The set method stores a value with a TTL in seconds. The invalidate method removes a specific key, and clear removes everything. Use a Map for O(1) lookups. Run a periodic cleanup to remove expired entries and prevent memory leaks in long-running servers.

typescript
1// src/cache.ts
2export class MemoryCache<T> {
3 private store = new Map<string, { value: T; expiresAt: number }>();
4 private cleanupInterval: NodeJS.Timeout;
5
6 constructor(cleanupMs: number = 60_000) {
7 this.cleanupInterval = setInterval(() => this.cleanup(), cleanupMs);
8 }
9
10 get(key: string): T | undefined {
11 const entry = this.store.get(key);
12 if (!entry) return undefined;
13 if (Date.now() > entry.expiresAt) {
14 this.store.delete(key);
15 return undefined;
16 }
17 return entry.value;
18 }
19
20 set(key: string, value: T, ttlSeconds: number): void {
21 this.store.set(key, {
22 value,
23 expiresAt: Date.now() + ttlSeconds * 1000,
24 });
25 }
26
27 invalidate(key: string): boolean {
28 return this.store.delete(key);
29 }
30
31 invalidatePattern(pattern: RegExp): number {
32 let count = 0;
33 for (const key of this.store.keys()) {
34 if (pattern.test(key)) {
35 this.store.delete(key);
36 count++;
37 }
38 }
39 return count;
40 }
41
42 clear(): void {
43 this.store.clear();
44 }
45
46 private cleanup(): void {
47 const now = Date.now();
48 for (const [key, entry] of this.store) {
49 if (now > entry.expiresAt) this.store.delete(key);
50 }
51 }
52
53 destroy(): void {
54 clearInterval(this.cleanupInterval);
55 this.store.clear();
56 }
57}

Expected result: A reusable MemoryCache class that stores values with automatic TTL expiration.

2

Generate consistent cache keys from tool inputs

Cache keys must be deterministic — the same inputs must always produce the same key. Create a helper that combines the tool name with a sorted, stringified version of the parameters object. Sorting keys ensures that { a: 1, b: 2 } and { b: 2, a: 1 } produce the same cache key. Prefix keys with the tool name to avoid collisions between different tools.

typescript
1// src/cache-keys.ts
2export function cacheKey(toolName: string, params: Record<string, unknown>): string {
3 const sortedParams = Object.keys(params)
4 .sort()
5 .reduce((acc, key) => {
6 acc[key] = params[key];
7 return acc;
8 }, {} as Record<string, unknown>);
9 return `${toolName}:${JSON.stringify(sortedParams)}`;
10}
11
12// Usage: cacheKey("search_files", { pattern: "*.ts", directory: "src" })
13// Returns: 'search_files:{"directory":"src","pattern":"*.ts"}'

Expected result: A cacheKey function that produces identical keys for identical inputs regardless of property order.

3

Wrap tool handlers with caching logic

Create a higher-order function that wraps any tool handler with cache-check logic. Before executing the handler, it checks the cache. On a hit, it returns the cached result immediately. On a miss, it runs the handler, caches the result, and returns it. Configure TTL per tool — read-heavy tools like search get longer TTLs, while tools that return volatile data get shorter ones.

typescript
1// src/cached-tool.ts
2import { MemoryCache } from "./cache.js";
3import { cacheKey } from "./cache-keys.js";
4
5const cache = new MemoryCache<unknown>();
6
7interface CacheConfig {
8 ttlSeconds: number;
9 enabled: boolean;
10}
11
12const TOOL_CACHE_CONFIG: Record<string, CacheConfig> = {
13 list_files: { ttlSeconds: 30, enabled: true },
14 read_file: { ttlSeconds: 60, enabled: true },
15 search_files: { ttlSeconds: 120, enabled: true },
16 write_file: { ttlSeconds: 0, enabled: false }, // Never cache writes
17 get_stats: { ttlSeconds: 5, enabled: true },
18};
19
20export function withCache<TParams extends Record<string, unknown>>(
21 toolName: string,
22 handler: (params: TParams) => Promise<unknown>
23): (params: TParams) => Promise<unknown> {
24 return async (params: TParams) => {
25 const config = TOOL_CACHE_CONFIG[toolName];
26 if (!config?.enabled) return handler(params);
27
28 const key = cacheKey(toolName, params);
29 const cached = cache.get(key);
30 if (cached !== undefined) {
31 console.error(`[cache] HIT ${toolName}`);
32 return cached;
33 }
34
35 console.error(`[cache] MISS ${toolName}`);
36 const result = await handler(params);
37 cache.set(key, result, config.ttlSeconds);
38 return result;
39 };
40}
41
42export function invalidateToolCache(toolName: string): void {
43 cache.invalidatePattern(new RegExp(`^${toolName}:`));
44}

Expected result: A withCache wrapper that transparently adds caching to any tool handler function.

4

Add Redis caching for multi-instance production deployments

For servers running on multiple instances behind a load balancer, in-memory caching does not share state. Use Redis as a shared cache layer. The ioredis library provides a reliable Redis client for Node.js. Create a RedisCache class with the same interface as MemoryCache so you can swap between them based on configuration. Redis handles TTL natively with the EX option.

typescript
1// src/redis-cache.ts
2import Redis from "ioredis";
3
4export class RedisCache<T> {
5 private client: Redis;
6 private prefix: string;
7
8 constructor(redisUrl: string, prefix: string = "mcp:") {
9 this.client = new Redis(redisUrl);
10 this.prefix = prefix;
11 }
12
13 async get(key: string): Promise<T | undefined> {
14 const raw = await this.client.get(this.prefix + key);
15 if (!raw) return undefined;
16 return JSON.parse(raw) as T;
17 }
18
19 async set(key: string, value: T, ttlSeconds: number): Promise<void> {
20 await this.client.set(
21 this.prefix + key,
22 JSON.stringify(value),
23 "EX",
24 ttlSeconds
25 );
26 }
27
28 async invalidate(key: string): Promise<boolean> {
29 const result = await this.client.del(this.prefix + key);
30 return result > 0;
31 }
32
33 async invalidatePattern(pattern: string): Promise<number> {
34 const keys = await this.client.keys(this.prefix + pattern);
35 if (keys.length === 0) return 0;
36 return this.client.del(...keys);
37 }
38
39 async disconnect(): Promise<void> {
40 await this.client.quit();
41 }
42}

Expected result: A RedisCache class that can be used as a drop-in replacement for MemoryCache in multi-instance deployments.

5

Invalidate cache entries when data changes

When a write tool modifies data, invalidate related cache entries so read tools return fresh results. After a write_file operation, clear all cached read_file and list_files entries for that directory. Use the invalidatePattern method to remove keys that match the affected paths. This ensures cache consistency without clearing the entire cache.

typescript
1// In your write_file tool handler:
2server.tool(
3 "write_file",
4 "Write content to a file",
5 { filePath: z.string(), content: z.string() },
6 async ({ filePath, content }) => {
7 const p = safePath(ctx.basePath, filePath);
8 if (!p) return errorResult("Path traversal not allowed");
9
10 await fs.writeFile(p, content, "utf-8");
11
12 // Invalidate caches for affected paths
13 const dir = path.dirname(filePath);
14 invalidateToolCache("read_file"); // Clear all read_file cache
15 invalidateToolCache("list_files"); // Clear all list_files cache
16 invalidateToolCache("search_files"); // Search results may have changed
17
18 console.error(`[cache] Invalidated caches after writing ${filePath}`);
19 return textResult(`Wrote ${content.length} bytes to ${filePath}`);
20 }
21);

Expected result: Write operations automatically clear related cache entries, ensuring subsequent reads return fresh data.

Complete working example

src/cache.ts
1export class MemoryCache<T> {
2 private store = new Map<string, { value: T; expiresAt: number }>();
3 private cleanupInterval: NodeJS.Timeout;
4
5 constructor(cleanupMs: number = 60_000) {
6 this.cleanupInterval = setInterval(() => this.cleanup(), cleanupMs);
7 }
8
9 get(key: string): T | undefined {
10 const entry = this.store.get(key);
11 if (!entry) return undefined;
12 if (Date.now() > entry.expiresAt) {
13 this.store.delete(key);
14 return undefined;
15 }
16 return entry.value;
17 }
18
19 set(key: string, value: T, ttlSeconds: number): void {
20 this.store.set(key, {
21 value,
22 expiresAt: Date.now() + ttlSeconds * 1000,
23 });
24 }
25
26 invalidate(key: string): boolean {
27 return this.store.delete(key);
28 }
29
30 invalidatePattern(pattern: RegExp): number {
31 let count = 0;
32 for (const key of this.store.keys()) {
33 if (pattern.test(key)) {
34 this.store.delete(key);
35 count++;
36 }
37 }
38 return count;
39 }
40
41 clear(): void { this.store.clear(); }
42
43 get size(): number { return this.store.size; }
44
45 private cleanup(): void {
46 const now = Date.now();
47 for (const [key, entry] of this.store) {
48 if (now > entry.expiresAt) this.store.delete(key);
49 }
50 }
51
52 destroy(): void {
53 clearInterval(this.cleanupInterval);
54 this.store.clear();
55 }
56}
57
58export function cacheKey(
59 toolName: string,
60 params: Record<string, unknown>
61): string {
62 const sorted = Object.keys(params)
63 .sort()
64 .reduce((a, k) => { a[k] = params[k]; return a; }, {} as Record<string, unknown>);
65 return `${toolName}:${JSON.stringify(sorted)}`;
66}

Common mistakes when adding caching to an MCP server for performance

Why it's a problem: Caching write operations or tools with side effects, causing stale reads

How to avoid: Only cache pure read operations. Set enabled: false in the cache config for any tool that modifies state.

Why it's a problem: Not setting TTL values, leading to permanently stale data

How to avoid: Always set a TTL. Even long-lived data should have a TTL of 300-600 seconds as a safety net.

Why it's a problem: Using non-deterministic cache keys (e.g., including timestamps or random values)

How to avoid: Sort parameter keys before stringifying and only include the actual tool inputs in the cache key.

Why it's a problem: Never clearing expired entries from the in-memory cache, causing memory leaks

How to avoid: Run periodic cleanup (every 60 seconds) to delete expired entries from the Map.

Best practices

  • Use short TTLs (5-30 seconds) for volatile data and longer TTLs (60-300 seconds) for stable data
  • Never cache tools that have side effects like file writes or API mutations
  • Log cache hits and misses to stderr for debugging and monitoring
  • Use Redis for production deployments with multiple server instances
  • Implement pattern-based invalidation so write operations can clear related read caches
  • Set a maximum cache size to prevent memory exhaustion on high-traffic servers
  • Include tool name as a prefix in cache keys to avoid collisions between tools
  • Test cache behavior explicitly — verify that hits, misses, and invalidation all work correctly

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I have an MCP server in TypeScript with 5 tools. Show me how to add an in-memory caching layer with TTL, cache key generation, and invalidation when write tools modify data. Include a Redis option for production.

MCP Prompt

Add caching to my MCP server tools. Create a MemoryCache class with TTL, a cacheKey helper, and a withCache wrapper function. Show how to invalidate cache entries when write operations occur.

Frequently asked questions

Should I cache all MCP tool responses?

No. Only cache tools that perform expensive, repeatable read operations. Never cache tools that modify state (writes, deletes, API mutations) or tools that must always return real-time data.

What is a good default TTL for MCP tool caching?

Start with 30-60 seconds for most read tools. Adjust based on how frequently the underlying data changes. File listings might use 30 seconds, while API responses for rarely changing data could use 300 seconds.

When should I use Redis instead of in-memory caching?

Use Redis when you run multiple server instances (horizontal scaling), need cache persistence across server restarts, or want to share cache between different MCP servers. For single-instance servers, in-memory is simpler and faster.

How do I prevent the in-memory cache from using too much RAM?

Set a maximum cache size and evict the oldest entries when the limit is reached (LRU eviction). Also run periodic cleanup to remove expired entries. Monitor the cache.size property and set alerts if it grows unexpectedly.

Can I cache MCP resource responses too, not just tool responses?

Yes, the same caching patterns apply to MCP resources. Wrap your resource handler with the same withCache function and use the resource URI as part of the cache key.

Does RapidDev recommend any specific caching strategy for MCP servers?

RapidDev typically recommends starting with in-memory caching for development and switching to Redis for production. They suggest tool-specific TTL configuration so you can tune each tool's cache behavior independently.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.