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
Build an in-memory cache class with TTL support
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.
1// src/cache.ts2export class MemoryCache<T> {3 private store = new Map<string, { value: T; expiresAt: number }>();4 private cleanupInterval: NodeJS.Timeout;56 constructor(cleanupMs: number = 60_000) {7 this.cleanupInterval = setInterval(() => this.cleanup(), cleanupMs);8 }910 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 }1920 set(key: string, value: T, ttlSeconds: number): void {21 this.store.set(key, {22 value,23 expiresAt: Date.now() + ttlSeconds * 1000,24 });25 }2627 invalidate(key: string): boolean {28 return this.store.delete(key);29 }3031 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 }4142 clear(): void {43 this.store.clear();44 }4546 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 }5253 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.
Generate consistent cache keys from tool inputs
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.
1// src/cache-keys.ts2export 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}1112// 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.
Wrap tool handlers with caching logic
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.
1// src/cached-tool.ts2import { MemoryCache } from "./cache.js";3import { cacheKey } from "./cache-keys.js";45const cache = new MemoryCache<unknown>();67interface CacheConfig {8 ttlSeconds: number;9 enabled: boolean;10}1112const 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 writes17 get_stats: { ttlSeconds: 5, enabled: true },18};1920export 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);2728 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 }3435 console.error(`[cache] MISS ${toolName}`);36 const result = await handler(params);37 cache.set(key, result, config.ttlSeconds);38 return result;39 };40}4142export 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.
Add Redis caching for multi-instance production deployments
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.
1// src/redis-cache.ts2import Redis from "ioredis";34export class RedisCache<T> {5 private client: Redis;6 private prefix: string;78 constructor(redisUrl: string, prefix: string = "mcp:") {9 this.client = new Redis(redisUrl);10 this.prefix = prefix;11 }1213 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 }1819 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 ttlSeconds25 );26 }2728 async invalidate(key: string): Promise<boolean> {29 const result = await this.client.del(this.prefix + key);30 return result > 0;31 }3233 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 }3839 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.
Invalidate cache entries when data changes
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.
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");910 await fs.writeFile(p, content, "utf-8");1112 // Invalidate caches for affected paths13 const dir = path.dirname(filePath);14 invalidateToolCache("read_file"); // Clear all read_file cache15 invalidateToolCache("list_files"); // Clear all list_files cache16 invalidateToolCache("search_files"); // Search results may have changed1718 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
1export class MemoryCache<T> {2 private store = new Map<string, { value: T; expiresAt: number }>();3 private cleanupInterval: NodeJS.Timeout;45 constructor(cleanupMs: number = 60_000) {6 this.cleanupInterval = setInterval(() => this.cleanup(), cleanupMs);7 }89 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 }1819 set(key: string, value: T, ttlSeconds: number): void {20 this.store.set(key, {21 value,22 expiresAt: Date.now() + ttlSeconds * 1000,23 });24 }2526 invalidate(key: string): boolean {27 return this.store.delete(key);28 }2930 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 }4041 clear(): void { this.store.clear(); }4243 get size(): number { return this.store.size; }4445 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 }5152 destroy(): void {53 clearInterval(this.cleanupInterval);54 this.store.clear();55 }56}5758export 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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation