{"slug": "mcp-cachescope-stop-private-results-leaking-across-users", "title": "MCP cacheScope: Stop Private Results Leaking Across Users", "summary": "A developer demonstrated a security flaw in the Model Context Protocol (MCP) caching system where private results can leak between users if the cache key lacks authorization context. The fix involves adding a cache partition derived from a stable authorization identity, such as a subject or tenant, to prevent cross-user data exposure. The developer's tests show that without partitioning, one user's private tool list can be served to another, and with partitioning, each user sees only their own data.", "body_md": "MCP `cacheScope`\n\naddresses a subtle problem: a response can be fresh and still be unsafe for another user.\n\nThe stable [2026-07-28 MCP specification](https://modelcontextprotocol.io/specification/2026-07-28/changelog) defines caching hints for reusable results. A server can mark a result `public`\n\nor `private`\n\n, while `ttlMs`\n\nsays how long it may remain fresh. But a shared client cache still needs enough identity information to keep private entries apart.\n\nIf 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.\n\nThe [MCP caching specification](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching) covers discovery, tool and prompt lists, resource lists, resource templates, and resource reads.\n\nIts scopes have different meanings:\n\n`public`\n\nresults may be reused across authorization contexts, even when the endpoint requires authentication.`private`\n\nresults may be reused only within the same authorization context.For a user-specific tool catalog, the server can describe the result like this:\n\n``` js\nconst server = new McpServer(\n  { name: \"private-catalog\", version: \"1.0.0\" },\n  {\n    cacheHints: {\n      \"tools/list\": {\n        ttlMs: 60_000,\n        cacheScope: \"private\",\n      },\n    },\n  },\n);\n```\n\nThat hint communicates the server's caching intent. It does not tell a shared cache which caller made the request.\n\nA cache key already needs the method and every parameter that can change the result. Authorization identity normally travels outside the `tools/list`\n\nparameters, so the client needs a separate partition for it.\n\nThe dangerous shape is small: two authorization contexts, one response cache, and no partition.\n\n``` js\nconst sharedCache = new InMemoryResponseCacheStore();\n\nfunction unpartitionedClient() {\n  return new Client(\n    { name: \"shared-gateway\", version: \"1.0.0\" },\n    { responseCacheStore: sharedCache },\n  );\n}\n```\n\nMy 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.\n\nAlice calls `tools/list`\n\nfirst, putting her private result into the shared store. Bob then calls the same method with the same parameters and server identity.\n\nWithout `cachePartition`\n\n, the lookup does not distinguish the authorization contexts. Bob receives Alice's cached tool list, and his endpoint handles zero `tools/list`\n\nrequests. The official [TypeScript SDK v2 caching guide](https://ts.sdk.modelcontextprotocol.io/v2/clients/caching.html) warns that this configuration can serve one user's private response body to another.\n\nThe 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.\n\nThe fix is to give each authorization context a stable cache partition:\n\n``` js\nconst sharedCache = new InMemoryResponseCacheStore();\n\nfunction clientFor(cachePartition: string) {\n  return new Client(\n    { name: \"shared-gateway\", version: \"1.0.0\" },\n    {\n      responseCacheStore: sharedCache,\n      cachePartition,\n    },\n  );\n}\n\nconst alice = clientFor(\"subject:alice\");\nconst bob = clientFor(\"subject:bob\");\n```\n\nNow 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.\n\nI 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.\n\nThe 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.\n\nMy review checklist is short:\n\n`private`\n\n.`cachePartition`\n\nwhenever one store serves multiple principals.The runnable [TypeScript regression sample](https://github.com/ssukhpinder/dev-to-code-samples/pull/4) includes the unsafe reproduction and the partitioned fix.\n\n`cacheScope`\n\ncontrols cache reuse. It does not grant access. The server must still authenticate the caller and authorize every uncached request.\n\nA 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.\n\nOther protocol boundaries matter too. Multi-round-trip retry requests carrying `inputResponses`\n\nor `requestState`\n\nmust not be cached. An `input_required`\n\nresult 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.\n\nFor 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.\n\nDoes your MCP client cache know which authorization context owns each private result?\n\nHappy coding!", "url": "https://wpnews.pro/news/mcp-cachescope-stop-private-results-leaking-across-users", "canonical_source": "https://dev.to/ssukhpinder/mcp-cachescope-stop-private-results-leaking-across-users-13g4", "published_at": "2026-08-15 02:59:44+00:00", "updated_at": "2026-08-15 03:10:41.529249+00:00", "lang": "en", "topics": ["ai-infrastructure", "ai-safety", "developer-tools"], "entities": ["Model Context Protocol", "MCP", "TypeScript SDK"], "alternates": {"html": "https://wpnews.pro/news/mcp-cachescope-stop-private-results-leaking-across-users", "markdown": "https://wpnews.pro/news/mcp-cachescope-stop-private-results-leaking-across-users.md", "text": "https://wpnews.pro/news/mcp-cachescope-stop-private-results-leaking-across-users.txt", "jsonld": "https://wpnews.pro/news/mcp-cachescope-stop-private-results-leaking-across-users.jsonld"}}