# How to add caching to an MCP server for performance

- Tool: MCP
- Difficulty: Advanced
- Time required: 25-35 min
- Compatibility: MCP TypeScript SDK v1.x, Node.js 18+, optional Redis 7+
- Last updated: March 2026

## 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.

## Before you start

- 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.

```
// src/cache.ts
export class MemoryCache<T> {
  private store = new Map<string, { value: T; expiresAt: number }>();
  private cleanupInterval: NodeJS.Timeout;

  constructor(cleanupMs: number = 60_000) {
    this.cleanupInterval = setInterval(() => this.cleanup(), cleanupMs);
  }

  get(key: string): T | undefined {
    const entry = this.store.get(key);
    if (!entry) return undefined;
    if (Date.now() > entry.expiresAt) {
      this.store.delete(key);
      return undefined;
    }
    return entry.value;
  }

  set(key: string, value: T, ttlSeconds: number): void {
    this.store.set(key, {
      value,
      expiresAt: Date.now() + ttlSeconds * 1000,
    });
  }

  invalidate(key: string): boolean {
    return this.store.delete(key);
  }

  invalidatePattern(pattern: RegExp): number {
    let count = 0;
    for (const key of this.store.keys()) {
      if (pattern.test(key)) {
        this.store.delete(key);
        count++;
      }
    }
    return count;
  }

  clear(): void {
    this.store.clear();
  }

  private cleanup(): void {
    const now = Date.now();
    for (const [key, entry] of this.store) {
      if (now > entry.expiresAt) this.store.delete(key);
    }
  }

  destroy(): void {
    clearInterval(this.cleanupInterval);
    this.store.clear();
  }
}
```

**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.

```
// src/cache-keys.ts
export function cacheKey(toolName: string, params: Record<string, unknown>): string {
  const sortedParams = Object.keys(params)
    .sort()
    .reduce((acc, key) => {
      acc[key] = params[key];
      return acc;
    }, {} as Record<string, unknown>);
  return `${toolName}:${JSON.stringify(sortedParams)}`;
}

// Usage: cacheKey("search_files", { pattern: "*.ts", directory: "src" })
// 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.

```
// src/cached-tool.ts
import { MemoryCache } from "./cache.js";
import { cacheKey } from "./cache-keys.js";

const cache = new MemoryCache<unknown>();

interface CacheConfig {
  ttlSeconds: number;
  enabled: boolean;
}

const TOOL_CACHE_CONFIG: Record<string, CacheConfig> = {
  list_files: { ttlSeconds: 30, enabled: true },
  read_file: { ttlSeconds: 60, enabled: true },
  search_files: { ttlSeconds: 120, enabled: true },
  write_file: { ttlSeconds: 0, enabled: false },  // Never cache writes
  get_stats: { ttlSeconds: 5, enabled: true },
};

export function withCache<TParams extends Record<string, unknown>>(
  toolName: string,
  handler: (params: TParams) => Promise<unknown>
): (params: TParams) => Promise<unknown> {
  return async (params: TParams) => {
    const config = TOOL_CACHE_CONFIG[toolName];
    if (!config?.enabled) return handler(params);

    const key = cacheKey(toolName, params);
    const cached = cache.get(key);
    if (cached !== undefined) {
      console.error(`[cache] HIT ${toolName}`);
      return cached;
    }

    console.error(`[cache] MISS ${toolName}`);
    const result = await handler(params);
    cache.set(key, result, config.ttlSeconds);
    return result;
  };
}

export function invalidateToolCache(toolName: string): void {
  cache.invalidatePattern(new RegExp(`^${toolName}:`));
}
```

**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.

```
// src/redis-cache.ts
import Redis from "ioredis";

export class RedisCache<T> {
  private client: Redis;
  private prefix: string;

  constructor(redisUrl: string, prefix: string = "mcp:") {
    this.client = new Redis(redisUrl);
    this.prefix = prefix;
  }

  async get(key: string): Promise<T | undefined> {
    const raw = await this.client.get(this.prefix + key);
    if (!raw) return undefined;
    return JSON.parse(raw) as T;
  }

  async set(key: string, value: T, ttlSeconds: number): Promise<void> {
    await this.client.set(
      this.prefix + key,
      JSON.stringify(value),
      "EX",
      ttlSeconds
    );
  }

  async invalidate(key: string): Promise<boolean> {
    const result = await this.client.del(this.prefix + key);
    return result > 0;
  }

  async invalidatePattern(pattern: string): Promise<number> {
    const keys = await this.client.keys(this.prefix + pattern);
    if (keys.length === 0) return 0;
    return this.client.del(...keys);
  }

  async disconnect(): Promise<void> {
    await this.client.quit();
  }
}
```

**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.

```
// In your write_file tool handler:
server.tool(
  "write_file",
  "Write content to a file",
  { filePath: z.string(), content: z.string() },
  async ({ filePath, content }) => {
    const p = safePath(ctx.basePath, filePath);
    if (!p) return errorResult("Path traversal not allowed");

    await fs.writeFile(p, content, "utf-8");

    // Invalidate caches for affected paths
    const dir = path.dirname(filePath);
    invalidateToolCache("read_file");   // Clear all read_file cache
    invalidateToolCache("list_files");  // Clear all list_files cache
    invalidateToolCache("search_files"); // Search results may have changed

    console.error(`[cache] Invalidated caches after writing ${filePath}`);
    return textResult(`Wrote ${content.length} bytes to ${filePath}`);
  }
);
```

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

## Complete code example

File: `src/cache.ts`

```typescript
export class MemoryCache<T> {
  private store = new Map<string, { value: T; expiresAt: number }>();
  private cleanupInterval: NodeJS.Timeout;

  constructor(cleanupMs: number = 60_000) {
    this.cleanupInterval = setInterval(() => this.cleanup(), cleanupMs);
  }

  get(key: string): T | undefined {
    const entry = this.store.get(key);
    if (!entry) return undefined;
    if (Date.now() > entry.expiresAt) {
      this.store.delete(key);
      return undefined;
    }
    return entry.value;
  }

  set(key: string, value: T, ttlSeconds: number): void {
    this.store.set(key, {
      value,
      expiresAt: Date.now() + ttlSeconds * 1000,
    });
  }

  invalidate(key: string): boolean {
    return this.store.delete(key);
  }

  invalidatePattern(pattern: RegExp): number {
    let count = 0;
    for (const key of this.store.keys()) {
      if (pattern.test(key)) {
        this.store.delete(key);
        count++;
      }
    }
    return count;
  }

  clear(): void { this.store.clear(); }

  get size(): number { return this.store.size; }

  private cleanup(): void {
    const now = Date.now();
    for (const [key, entry] of this.store) {
      if (now > entry.expiresAt) this.store.delete(key);
    }
  }

  destroy(): void {
    clearInterval(this.cleanupInterval);
    this.store.clear();
  }
}

export function cacheKey(
  toolName: string,
  params: Record<string, unknown>
): string {
  const sorted = Object.keys(params)
    .sort()
    .reduce((a, k) => { a[k] = params[k]; return a; }, {} as Record<string, unknown>);
  return `${toolName}:${JSON.stringify(sorted)}`;
}
```

## Common mistakes

- **Caching write operations or tools with side effects, causing stale reads** — undefined Fix: Only cache pure read operations. Set enabled: false in the cache config for any tool that modifies state.
- **Not setting TTL values, leading to permanently stale data** — undefined Fix: Always set a TTL. Even long-lived data should have a TTL of 300-600 seconds as a safety net.
- **Using non-deterministic cache keys (e.g., including timestamps or random values)** — undefined Fix: Sort parameter keys before stringifying and only include the actual tool inputs in the cache key.
- **Never clearing expired entries from the in-memory cache, causing memory leaks** — undefined Fix: 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

## 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.

---

Source: https://www.rapidevelopers.com/mcp-tutorial/how-to-add-caching-to-mcp-server
© RapidDev — https://www.rapidevelopers.com/mcp-tutorial/how-to-add-caching-to-mcp-server
