everything: session resources evict another session's resource when two sessions use the same file name
Environment
- repo: modelcontextprotocol/servers @
d73f99efbfd40c3aa1b61e88728b3d49fb52608f
- server:
src/everything (server-everything)
- Node.js v22 (ESM),
@modelcontextprotocol/sdk as pinned in the repo lockfile
- OS: macOS (Darwin 25.6.0)
- No network / no API keys needed: the repro drives two real
Clients against real McpServers over InMemoryTransport using data: URIs.
Minimal reproduction
/tmp/repro-d/session-resource-collision.mjs — start one McpServer per session via createServer(), connect a Client to each over InMemoryTransport, then call the gzip-file-as-resource tool (its name defaults to README.md.gz, tools/gzip-file-as-resource.ts:28) from each session:
const BUILD = process.argv[2];
const { createServer } = await import(`${BUILD}/server/index.js`);
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
const { InMemoryTransport } = await import("@modelcontextprotocol/sdk/inMemory.js");
const URI = "demo://resource/session/shared.gz";
async function newSession(label) {
const { server } = createServer();
const [clientT, serverT] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: `client-${label}`, version: "1.0.0" });
await Promise.all([client.connect(clientT), server.connect(serverT)]);
return client;
}
const gzip = (name, payload) => ({
name: "gzip-file-as-resource",
arguments: { name, data: `data:text/plain,${payload}`, outputType: "resourceLink" },
});
const a = await newSession("A");
// 1. Session A registers its session resource
await a.callTool(gzip("shared.gz", "hello-from-session-A"));
const readA1 = await a.readResource({ uri: URI });
console.log(`1. A reads its own resource -> OK (${readA1.contents.length} content)`);
// 2. control: a third session registers a DIFFERENT name -> A is unaffected
const c = await newSession("C");
await c.callTool(gzip("other.gz", "session-C"));
await a.readResource({ uri: URI });
console.log("2. C registers other.gz, A reads again -> still OK (control)");
// 3. Session B registers the SAME name -> evicts A's resource
const b = await newSession("B");
await b.callTool(gzip("shared.gz", "hello-from-session-B"));
try {
await a.readResource({ uri: URI });
console.log("3. B registers shared.gz, A reads -> still OK");
} catch (error) {
console.log(`3. B registers shared.gz, A reads -> FAILED: ${error.message}`);
}
const readB = await b.readResource({ uri: URI });
console.log(`4. B reads its own resource -> OK (${readB.contents.length} content)`);
const listA = await a.listResources();
console.log(
`5. session A resources/list still has it? -> ${listA.resources.some((r) => r.uri === URI)}`,
);
process.exit(0);
Actual output
Verifier 1 (verbatim):
A1 after A register: 1 content(s)
A2 after B register: FAILED -32602 MCP error -32602: MCP error -32602: Resource demo://resource/session/shared.gz not found
A list contains URI: false
B list contains URI: true
Verifier 2 (verbatim):
1. A reads its own resource -> OK (1 content)
2. C registers other.gz, A reads again -> still OK (control)
3. B registers shared.gz, A reads -> FAILED: MCP error -32602: MCP error -32602: Resource demo://resource/session/shared.gz not found
4. B reads its own resource -> OK (1 content)
5. session A resources/list still has it? -> false
Expected behaviour and basis
Expected: session B registering its own demo://resource/session/shared.gz must not affect session A's identically named resource; both sessions keep serving their own content for the life of their own session.
Basis in this repo:
src/everything/docs/features.md:44 — "Session Scoped: demo://resource/session/<name> (per-session resources registered dynamically; available only for the lifetime of the session)".
src/everything/docs/how-it-works.md:36 — "The content is served from memory for the life of the session only."
src/everything/resources/session.ts:24 docstring — "The registered resource is available during the life of the session only; it is not otherwise persisted."
- Each SSE / streamableHttp session gets its own
McpServer from createServer() (src/everything/server/index.ts:35), so nothing about the registry is meant to cross sessions. The sibling src/everything/resources/subscriptions.ts keys all state by sessionId — the repo's own per-session pattern.
- No doc scopes session resources globally or claims session resource names must be unique across sessions.
- The module-level
Map was introduced by 3e1be88 ("fix(everything): allow re-registration of session resources"), whose commit message scopes its purpose to within a session: "a tool like gzip-file-as-resource is called multiple times with the same output name ... which is important for LLM agents that may retry tool calls." The cross-session eviction is an unintended side effect of that fix, not a product decision.
Root cause
src/everything/resources/session.ts:9 declares registeredResources as a module-level Map<string, RegisteredResource> keyed by URI only, and it is shared by every session's McpServer. registerSessionResource looks up that global map at line 58, and when an entry exists it calls existingResource.remove() at line 60 — unregistering the resource from whichever server originally registered it — then overwrites the entry at line 77. So when session B registers a URI that session A already registered, A's resource is removed from A's server and A's later resources/read returns -32602 while the resource also disappears from A's resources/list.
Proposed fix
Scope the registry to the McpServer that owns the resource so remove()/set() only ever touch resources registered on that same server instance — e.g. keep WeakMap<McpServer, Map<string, RegisteredResource>> and look the per-server map up inside registerSessionResource. Happy to open a PR with this approach if it's welcome.
Related issues / PRs
- Collision checks (open issues and PRs referencing session resources /
registerSessionResource) returned none.
- Introducing commit:
3e1be88 — "fix(everything): allow re-registration of session resources" (the within-session retry fix that added the module-level map).
everything: session resources evict another session's resource when two sessions use the same file name
Environment
d73f99efbfd40c3aa1b61e88728b3d49fb52608fsrc/everything(server-everything)@modelcontextprotocol/sdkas pinned in the repo lockfileClients against realMcpServers overInMemoryTransportusingdata:URIs.Minimal reproduction
/tmp/repro-d/session-resource-collision.mjs— start oneMcpServerper session viacreateServer(), connect aClientto each overInMemoryTransport, then call thegzip-file-as-resourcetool (itsnamedefaults toREADME.md.gz,tools/gzip-file-as-resource.ts:28) from each session:Actual output
Verifier 1 (verbatim):
Verifier 2 (verbatim):
Expected behaviour and basis
Expected: session B registering its own
demo://resource/session/shared.gzmust not affect session A's identically named resource; both sessions keep serving their own content for the life of their own session.Basis in this repo:
src/everything/docs/features.md:44— "Session Scoped:demo://resource/session/<name>(per-session resources registered dynamically; available only for the lifetime of the session)".src/everything/docs/how-it-works.md:36— "The content is served from memory for the life of the session only."src/everything/resources/session.ts:24docstring — "The registered resource is available during the life of the session only; it is not otherwise persisted."McpServerfromcreateServer()(src/everything/server/index.ts:35), so nothing about the registry is meant to cross sessions. The siblingsrc/everything/resources/subscriptions.tskeys all state bysessionId— the repo's own per-session pattern.Mapwas introduced by3e1be88("fix(everything): allow re-registration of session resources"), whose commit message scopes its purpose to within a session: "a tool likegzip-file-as-resourceis called multiple times with the same output name ... which is important for LLM agents that may retry tool calls." The cross-session eviction is an unintended side effect of that fix, not a product decision.Root cause
src/everything/resources/session.ts:9declaresregisteredResourcesas a module-levelMap<string, RegisteredResource>keyed by URI only, and it is shared by every session'sMcpServer.registerSessionResourcelooks up that global map at line 58, and when an entry exists it callsexistingResource.remove()at line 60 — unregistering the resource from whichever server originally registered it — then overwrites the entry at line 77. So when session B registers a URI that session A already registered, A's resource is removed from A's server and A's laterresources/readreturns-32602while the resource also disappears from A'sresources/list.Proposed fix
Scope the registry to the
McpServerthat owns the resource soremove()/set()only ever touch resources registered on that same server instance — e.g. keepWeakMap<McpServer, Map<string, RegisteredResource>>and look the per-server map up insideregisterSessionResource. Happy to open a PR with this approach if it's welcome.Related issues / PRs
registerSessionResource) returned none.3e1be88— "fix(everything): allow re-registration of session resources" (the within-session retry fix that added the module-level map).