modelcontextprotocol / modelcontextprotocol/servers

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

Open
#4,808 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
90.5k
Forks
11.7k
Avg merge
2d 2h
Merged PRs (30d)
5

Description

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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with src/everything/resources/session.ts, then read how src/everything/server/index.ts creates an McpServer per session. Run the provided session-resource-collision.mjs reproduction against the built server and inspect the session resource registration flow. Done means identical resource names remain readable and listed independently in both sessions while re-registration still works within one session.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
backend-api-design
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
78/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.