Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/everything-session-resources-per-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@modelcontextprotocol/server-everything": patch
---

Session resources are now tracked per server, so two sessions that create a session resource with the same name (for example via `gzip-file-as-resource`) no longer evict each other's resource (#4808).
2 changes: 1 addition & 1 deletion .claude/skills/testing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ above the test (or above its `describe`/class when the whole block pins the
same bug):

```ts
// KNOWN BUG #4808: pins current (wrong) behavior; the fix changes this assertion.
// KNOWN BUG #<N>: pins current (wrong) behavior; the fix changes this assertion.
```

In Python the same text follows `#`. A bug with no issue yet reads
Expand Down
31 changes: 14 additions & 17 deletions src/everything/__tests__/gzip-file-as-resource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
* (#4854), with its default limits: fetching a `data:` or local `http:` URL,
* returning the gzipped file as a resource link or an embedded resource,
* registering it for later reads, and the errors for bad protocols, oversized
* or empty responses and failed fetches. It also pins #4808: two sessions that
* use the same file name evict each other's resource. The configurable limits
* are in `gzip-limits.test.ts`.
* or empty responses and failed fetches. It also guards #4808: two sessions
* that use the same file name each keep their own resource. The configurable
* limits are in `gzip-limits.test.ts`.
*/
import { createServer as createHttpServer, type Server } from "node:http";
import { once } from "node:events";
Expand Down Expand Up @@ -150,24 +150,21 @@ describe("gzip-file-as-resource", () => {
expect(gunzipSync(blob).toString()).toBe("second");
});

// KNOWN BUG #4808: pins current (wrong) behavior; the fix changes this assertion.
it("evicts another session's resource of the same name (#4808)", async () => {
// Characterization of #4808: session resources are tracked in one
// module-level map keyed by URI, so the second session's registration
// removes the first session's resource from the first session's server.
it("keeps each session's resource when two sessions use the same name (#4808)", async () => {
// Regression guard for #4808: session resources are tracked per server,
// so the second session's registration leaves the first session's
// resource (and its content) in place.
const a = await open();
const b = await open();
const second = `data:text/plain;base64,${Buffer.from("from b").toString("base64")}`;
await gzip(a, { name: "shared.gz", data: DATA_URI });
await gzip(b, { name: "shared.gz", data: DATA_URI });
await gzip(b, { name: "shared.gz", data: second });

await expect(
a.client.readResource({ uri: "demo://resource/session/shared.gz" }),
).rejects.toThrow("Resource demo://resource/session/shared.gz not found");
expect(
gunzipSync(
await readBlob(b, "demo://resource/session/shared.gz"),
).toString(),
).toBe(TEXT);
const uri = "demo://resource/session/shared.gz";
expect(gunzipSync(await readBlob(a, uri)).toString()).toBe(TEXT);
expect(gunzipSync(await readBlob(b, uri)).toString()).toBe("from b");
const { resources } = await a.client.listResources();
expect(resources.map((r) => r.uri)).toContain(uri);
});

it("rejects a URL that is not http, https or data", async () => {
Expand Down
25 changes: 17 additions & 8 deletions src/everything/resources/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ import {
import { Resource, ResourceLink } from "@modelcontextprotocol/sdk/types.js";

/**
* Tracks registered session resources by URI to allow updating/removing on re-registration.
* This prevents "Resource already registered" errors when a tool creates a resource
* with the same URI multiple times during a session.
* Tracks registered session resources per server, by URI, to allow updating/removing on
* re-registration. This prevents "Resource already registered" errors when a tool creates
* a resource with the same URI multiple times during a session, without touching another
* session's server that registered the same URI (#4808).
*/
const registeredResources = new Map<string, RegisteredResource>();
const registeredResources = new WeakMap<
McpServer,
Map<string, RegisteredResource>
>();

/**
* Generates a session-scoped resource URI string based on the provided resource name.
Expand Down Expand Up @@ -57,11 +61,16 @@ export const registerSessionResource = (
blob: payload,
};

// Check if a resource with this URI is already registered and remove it
const existingResource = registeredResources.get(uri);
const serverResources =
registeredResources.get(server) ?? new Map<string, RegisteredResource>();
registeredResources.set(server, serverResources);

// Check if a resource with this URI is already registered on this server and remove it
const resourceKey = uri.toString();
const existingResource = serverResources.get(resourceKey);
if (existingResource) {
existingResource.remove();
registeredResources.delete(uri);
serverResources.delete(resourceKey);
}

// Register file resource
Expand All @@ -77,7 +86,7 @@ export const registerSessionResource = (
);

// Track the registered resource for potential future removal
registeredResources.set(uri, registeredResource);
serverResources.set(resourceKey, registeredResource);

return { type: "resource_link", ...resource };
};
Loading