Skip to content

everything: session resources evict another session's resource when two sessions use the same file name #4808

Description

@BlueX888

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions