DEV Community

Sukhpinder Singh
Sukhpinder Singh

Posted on

MCP cacheScope: Stop Private Results Leaking Across Users

MCP cacheScope addresses a subtle problem: a response can be fresh and still be unsafe for another user.

The stable 2026-07-28 MCP specification defines caching hints for reusable results. A server can mark a result public or private, while ttlMs says how long it may remain fresh. But a shared client cache still needs enough identity information to keep private entries apart.

If Alice warms a cache and Bob later uses the same store, an incomplete cache key can return Alice's result to Bob. I treat that as a security boundary worth testing.

Why MCP cacheScope needs a cache partition

The MCP caching specification covers discovery, tool and prompt lists, resource lists, resource templates, and resource reads.

Its scopes have different meanings:

  • public results may be reused across authorization contexts, even when the endpoint requires authentication.
  • private results may be reused only within the same authorization context.

For a user-specific tool catalog, the server can describe the result like this:

const server = new McpServer(
  { name: "private-catalog", version: "1.0.0" },
  {
    cacheHints: {
      "tools/list": {
        ttlMs: 60_000,
        cacheScope: "private",
      },
    },
  },
);
Enter fullscreen mode Exit fullscreen mode

That hint communicates the server's caching intent. It does not tell a shared cache which caller made the request.

A cache key already needs the method and every parameter that can change the result. Authorization identity normally travels outside the tools/list parameters, so the client needs a separate partition for it.

Reproduce the leak with a shared store

The dangerous shape is small: two authorization contexts, one response cache, and no partition.

const sharedCache = new InMemoryResponseCacheStore();

function unpartitionedClient() {
  return new Client(
    { name: "shared-gateway", version: "1.0.0" },
    { responseCacheStore: sharedCache },
  );
}
Enter fullscreen mode Exit fullscreen mode

My demonstration uses two in-process endpoints with the same MCP server identity. One exposes an Alice-only tool; the other exposes a Bob-only tool. These endpoints stand in for the different results a real authenticated server would produce.

Alice calls tools/list first, putting her private result into the shared store. Bob then calls the same method with the same parameters and server identity.

Without cachePartition, the lookup does not distinguish the authorization contexts. Bob receives Alice's cached tool list, and his endpoint handles zero tools/list requests. The official TypeScript SDK v2 caching guide warns that this configuration can serve one user's private response body to another.

The test intentionally passes when it reproduces the unsafe result. That makes the failure mode visible without credentials, a network service, a model, or a paid API call.

Fix MCP cacheScope with a stable partition

The fix is to give each authorization context a stable cache partition:

const sharedCache = new InMemoryResponseCacheStore();

function clientFor(cachePartition: string) {
  return new Client(
    { name: "shared-gateway", version: "1.0.0" },
    {
      responseCacheStore: sharedCache,
      cachePartition,
    },
  );
}

const alice = clientFor("subject:alice");
const bob = clientFor("subject:bob");
Enter fullscreen mode Exit fullscreen mode

Now Alice's private entries live separately from Bob's. The same method, parameters, and server identity no longer resolve to the same private cache location. The second test proves that both endpoints receive one request and each client sees only its own tool.

I would derive the partition from a stable, opaque authorization identity. It must include every dimension that can change visibility, such as tenant, subject, role, or effective scope. A raw bearer token is a poor partition: it is secret material and can rotate while the underlying principal stays the same.

The SDK treats public entries differently. They remain shareable across principal partitions because the server explicitly declared them safe for cross-context reuse. That keeps the performance benefit without weakening private isolation.

My review checklist is short:

  1. Mark authorization-dependent results private.
  2. Configure cachePartition whenever one store serves multiple principals.
  3. Test two principals against the same method, parameters, and server identity.

The runnable TypeScript regression sample includes the unsafe reproduction and the partitioned fix.

Limits: cacheScope is not authorization

cacheScope controls cache reuse. It does not grant access. The server must still authenticate the caller and authorize every uncached request.

A TTL is a freshness hint, not an immediate revocation mechanism. If permissions change, waiting for a private entry to expire may be too slow. A compliant client must invalidate affected entries when it receives the corresponding MCP notification, while the application still needs a policy for authorization changes outside that flow.

Other protocol boundaries matter too. Multi-round-trip retry requests carrying inputResponses or requestState must not be cached. An input_required result is incomplete and is not cacheable. Pagination is cached one page at a time, uses the same scope across pages, and does not promise snapshot consistency.

For a single-principal process with a private, non-shared store, a partition may add little value. For gateways, desktop hosts, or services that multiplex users through one cache, I would make partition isolation a regression test rather than a configuration assumption.

Does your MCP client cache know which authorization context owns each private result?

Happy coding!

Top comments (2)

Collapse
 
reidmarlow profile image
Reid Marlow

The authorization partition is the bit I'd want in a test fixture, not just in review notes. It is too easy for a cache helper to key on method plus params and forget that the real input came from the bearer token. A tiny two-user regression test catches the scary version of this bug.

Collapse
 
mads_hansen_27b33ebfee4c9 profile image
Mads Hansen

Good distinction between freshness and ownership. I would split the partition identity from an authorization generation.

A stable tenant/subject partition survives token rotation, but it can also keep serving a result after the same subject loses a role or scope. One practical key is an opaque digest over server identity, tenant, subject, effective authorization-set digest, and a policy epoch. The epoch changes on membership, entitlement, or policy updates; the digest avoids putting raw claims or secrets into cache storage and logs.

The regression suite should include:

  • privilege downgrade while the TTL is still live
  • logout/revocation and delete-then-recreate of the same username
  • anonymous-to-authenticated transitions
  • an in-flight fill that completes after invalidation

That last case is easy to miss: invalidating first is not enough if an older request can repopulate the cache afterward. Tagging fills with the generation they started under and rejecting commits from stale generations closes the race. Per-scope hit/miss/invalidation metrics also make partition mistakes visible without logging principal identities.