teachyou.ai academy
← All posts
MCPserver performanceAPI designtool callingdeveloper tools

Caching Strategies for MCP Servers

Pramod Dutta · Jun 27, 2026 · 12 min read

MCP caching means storing the results of expensive tool calls, resource reads, and prompt fetches inside an MCP server so repeated requests skip redundant work. If you're building or operating a Model Context Protocol server, caching is not optional once you have more than one agent hitting it: without it, every tool call re-runs a database query, re-fetches an API, or re-computes an embedding, and your agent's context window fills up waiting on latency it didn't need to pay for. This article walks through where MCP caching actually helps, how to key and invalidate it correctly, and the specific failure modes that make cached MCP servers return stale or wrong data to an LLM that has no way to know it's wrong.

Why MCP servers need caching more than typical APIs

A regular REST API gets caching treated as an optimization. An MCP server gets caching treated as a correctness requirement, for three reasons specific to how agents use tools.

First, agents call tools repeatedly inside a single reasoning loop. A coding agent might call a search_codebase tool, get a partial answer, then call it again with a slightly different query two turns later. If the underlying index hasn't changed, that's a wasted round trip that also adds latency the user feels as "the agent is thinking."

Second, MCP tool descriptions and resource lists are fetched at session start and sometimes re-fetched mid-session. If your server rebuilds its tool schema or resource index from scratch on every list_tools or list_resources call, you're paying that cost on every new client connection, which adds up fast when you're running an MCP server behind something like Claude Code, Claude Desktop, or a custom agent host that reconnects often.

Third, and this is the one people miss: LLM cost and latency scale with what a tool call returns, not just whether it succeeds. A tool that returns a 40KB JSON blob because it re-queried a slow upstream API costs you twice, once in server compute and once in tokens the model has to read. Caching the transformed, trimmed response (not just the raw upstream call) is often where the real savings are.

Where to put the cache: three layers

MCP servers typically need caching at up to three layers, and conflating them is the most common design mistake.

Layer 1: Upstream call cache. This wraps whatever your tool calls out to (a database, a REST API, a subprocess). It's a classic cache: key on the exact request parameters, store the raw response, apply a TTL.

Layer 2: Tool result cache. This sits after you've transformed the upstream response into what you hand back to the model. Sometimes the transform itself is expensive (large diffs, embeddings, summarization), so caching post-transform saves more than caching the raw call.

Layer 3: Protocol metadata cache. list_tools, list_resources, list_prompts responses. These change rarely (only on server code deploys or config changes) so they can have long TTLs or be cached until explicit invalidation.

A server that only caches layer 1 will still pay transform cost on every hit. A server that only caches layer 2 duplicates data if the same upstream call feeds two different tools. Most production MCP servers need at least layers 1 and 3; layer 2 becomes worth it once you have expensive post-processing.

Building a cache key that doesn't lie

The single most common MCP caching bug is a cache key that's too coarse. If your tool signature is:

search_docs(query: str, project_id: str, max_results: int = 10)

and you key the cache on query alone, you will serve project A's results to project B. This sounds obvious written down, but it happens constantly when caching gets bolted on after the tool already works, because someone caches on the thing that varies most (the query) and forgets the thing that's "always the same" until it isn't (the project scope, the auth context, the max_results value).

The rule: hash every parameter that affects the output, including ones that come from the MCP session context rather than the explicit tool arguments (auth token scope, workspace ID, locale). A safe pattern in Python:

import hashlib
import json

def cache_key(tool_name: str, params: dict, context: dict) -> str:
    payload = {
        "tool": tool_name,
        "params": params,
        # only include context fields that actually affect output
        "workspace_id": context.get("workspace_id"),
        "auth_scope": context.get("auth_scope"),
    }
    blob = json.dumps(payload, sort_keys=True)
    return hashlib.sha256(blob.encode()).hexdigest()

Sorting keys before hashing matters: JSON dict ordering is not guaranteed to be stable across calls unless you enforce it, and an unstable cache key defeats the cache silently (you get cache misses forever, not wrong data, but you'll spend a week wondering why hit rate is zero).

TTL and staleness: match the tool's actual volatility

Every tool in your MCP server has a different tolerance for staleness, and a single global TTL is almost always wrong. Group your tools by volatility class instead of guessing per-tool:

  • Static or slow-changing (docs, schema definitions, config): TTL in hours or cache until explicit invalidation. A get_api_schema tool rarely needs fresher than 15 minutes.
  • User-scoped, moderate change rate (a user's open tickets, recent commits): TTL in the 30-300 second range. Fresh enough that an agent doesn't act on hour-old state, cheap enough to absorb repeated calls in one session.
  • Real-time (current server status, live metrics, account balance): do not cache, or cache for single-digit seconds only, and say so in the tool description so the model doesn't assume freshness it doesn't have.

Put the TTL choice in the tool's description text, not just in code comments. An MCP tool description like "Returns document content. Results may be cached up to 5 minutes." lets the calling model reason correctly about whether to trust a value it got two tool calls ago instead of calling again "just in case." This one line in the description prevents a surprising amount of redundant re-querying, because a well-instructed model will avoid re-fetching something it was told is fresh enough.

Invalidation: the part everyone skips

TTL-only caching (just wait for it to expire) is fine for read-heavy, low-consequence tools. It breaks down the moment your MCP server also exposes write tools that affect the same data your read tools cache. If you have create_ticket and list_tickets in the same server, and list_tickets is cached for 60 seconds, an agent that just created a ticket and immediately lists tickets will not see it. That's not a performance bug, it's a correctness bug that makes the agent think its own action failed.

Two practical fixes, pick based on your infrastructure:

  1. Write-through invalidation. Every write tool explicitly deletes or updates the relevant cache keys before returning. This requires your write and read tools to agree on cache key derivation, which is another reason to centralize the cache_key() function rather than inlining key logic per tool.
  1. Version-stamped reads. Store a monotonic version counter per resource (or per workspace). Cache entries are keyed with the version baked in; writes bump the version. Reads after a write automatically miss the old cache entry because the key changed, no explicit deletion needed. This is more robust under concurrent writers because there's no window where a delete-then-repopulate race can serve stale data.
class VersionedCache:
    def __init__(self):
        self._store = {}
        self._versions = {}

    def get_version(self, scope: str) -> int:
        return self._versions.get(scope, 0)

    def bump_version(self, scope: str) -> None:
        self._versions[scope] = self.get_version(scope) + 1

    def get(self, scope: str, key: str):
        versioned_key = (scope, self.get_version(scope), key)
        return self._store.get(versioned_key)

    def set(self, scope: str, key: str, value, ttl_seconds: int = 300):
        versioned_key = (scope, self.get_version(scope), key)
        self._store[versioned_key] = (value, ttl_seconds)

Call bump_version("ticket:acme-corp") inside create_ticket and every subsequent list_tickets read for that workspace automatically misses stale entries, no coordination needed between the two tool handlers beyond agreeing on the scope string.

Caching across MCP transport types

MCP servers run over stdio (local subprocess) or HTTP/SSE (remote). The caching approach differs meaningfully between them.

For stdio servers, the process usually lives for one client session, so an in-memory cache (a dict, an LRU) is often enough. There's no cross-session sharing to worry about because each session gets its own process. The tradeoff is that a cold start pays full cache-miss cost every time, which matters if your MCP client (Claude Desktop, Claude Code) spawns a fresh subprocess per session rather than reusing one.

For remote HTTP MCP servers, multiple sessions and multiple users hit the same running process, so you get real cache reuse across sessions, but you also need a cache layer that survives process restarts and horizontal scaling: Redis or an equivalent shared store, not an in-process dict. This is also where the cache-key scoping problem gets sharper, because now the "always the same" context you forgot to key on (workspace, auth scope) is actually different per request, and an in-memory-dict habit carried over from a stdio server design will leak data across users.

If you're running a remote MCP server behind multiple replicas, use a shared cache (Redis, Memcached) rather than per-replica in-memory caching, or you'll get inconsistent hit behavior where the same request sometimes hits cache and sometimes doesn't depending on which replica handled it, which makes debugging "why did the agent see different data twice" much harder than it needs to be.

Don't cache errors, but do cache "no results"

Two subtle bugs worth calling out separately.

Never cache a tool call that errored (timeout, upstream 500, auth failure). If you do, every retry for the TTL window returns the same cached failure instead of getting a fresh attempt, which turns a transient blip into a sustained outage from the agent's point of view. Cache only successful, well-formed responses.

Do cache legitimate empty results ("no documents matched this query"). Skipping the cache for empty results because "there's nothing to cache" means every repeated query for a genuinely empty result set re-runs the full upstream call, which is often the worst case for latency since the upstream has to scan everything to conclude there's nothing there.

A minimal caching wrapper for an MCP tool handler

Putting the pieces together, here's a decorator pattern that handles keying, TTL, and skip-on-error in one place, so individual tool handlers stay simple:

import time
import functools

_cache_store: dict[str, tuple[float, object]] = {}

def cached_tool(ttl_seconds: int = 60):
    def decorator(fn):
        @functools.wraps(fn)
        async def wrapper(params: dict, context: dict):
            key = cache_key(fn.__name__, params, context)
            now = time.monotonic()

            if key in _cache_store:
                expires_at, value = _cache_store[key]
                if now < expires_at:
                    return value
                del _cache_store[key]

            result = await fn(params, context)

            # never cache error responses
            if isinstance(result, dict) and result.get("error"):
                return result

            _cache_store[key] = (now + ttl_seconds, result)
            return result

        return wrapper
    return decorator


@cached_tool(ttl_seconds=120)
async def search_docs(params: dict, context: dict):
    # actual upstream call goes here
    ...

Swap _cache_store for a Redis client with the same get/set shape when you move from a single stdio process to a shared remote deployment, the decorator interface doesn't need to change.

Testing that your cache is actually correct

Before shipping MCP caching, run three checks that catch most real bugs:

  • Cross-scope leak test: call the same tool with identical arguments but two different auth contexts (two workspace IDs). Confirm you get two different, correctly-scoped results, not the first caller's cached data.
  • Write-then-read test: call a write tool, then immediately call the read tool that should reflect it. Confirm the read sees the write within your invalidation window, not after the old TTL expires.
  • Error-then-retry test: force an upstream failure, confirm the tool call errors, then confirm the very next call retries the upstream rather than replaying the cached failure.

These three tests map directly to the three bugs described above (coarse cache keys, missing invalidation, caching errors), and they're cheap to write as integration tests against your MCP server directly, no need for a full agent loop to exercise them.

FAQ

Does MCP itself provide built-in caching? No. The Model Context Protocol specification defines how tools, resources, and prompts are described and invoked, but caching is an implementation detail left to the server. Some MCP SDKs offer helper utilities for resource caching, but the keying, TTL, and invalidation logic described here is something you build into your server regardless of which SDK you use.

Should I cache on the client side (inside the agent host) or the server side (inside the MCP server)? Server side, in almost every case. The MCP server has the full context needed to key correctly (auth scope, workspace, upstream freshness) and to invalidate correctly when writes happen. Client-side caching of tool results is risky because the client usually doesn't know when server-side state changed, so it's easy to end up serving stale data with no invalidation path at all.

How do I avoid stale data confusing the model without disabling caching? Put the cache freshness bound into the tool's description or into the returned payload itself, for example a "cached_at" or "as_of" timestamp field in the response. A model that can see the data is 40 seconds old reasons differently than a model that assumes every tool call is live, and this costs you one extra field, not a caching redesign.

What's a reasonable default TTL if I don't know my tool's volatility yet? Start short, 30 to 60 seconds, and only extend it for tools you've confirmed are backed by slow-changing data. It's much easier to notice "this cache is too aggressive, extend the TTL" from low hit rates than to debug "the agent acted on 20-minute-old data" after the fact, so bias toward under-caching early.

Does caching help with MCP server cost if I'm not paying per API call upstream? Yes, indirectly. Even with a free or flat-rate upstream, caching reduces latency, which reduces how long the agent session runs, which reduces the number of tokens spent on the model "waiting" through intermediate reasoning steps and reduces the total tokens billed for the conversation. Latency reduction is often the bigger win than literal compute savings.

Can caching break tool idempotency guarantees an agent relies on? It can, if you accidentally cache a tool that's supposed to have side effects on every call (like send_notification or increment_counter). Only apply caching to tools that are pure reads with respect to the data they return; never wrap a tool that has side effects in a result cache, since the agent expects every call to actually execute, not return a memoized answer from the last call.