Skip to content

Commit 3f867a0

Browse files
cliffhallclaude
andcommitted
feat(everything): add a resource collection example
One resources/read of demo://resource/collection/summer-specials, or of the demo://resource/collection/by-category/{category} template, returns several contents, each under its item's own demo://resource/collection/item/{itemId} URI, which reads back on its own. Both templates complete their variable; a category or item not in the catalog is not found (-32602). Served to both protocol eras, with no state. Closes #3130 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]>
1 parent 446be48 commit 3f867a0

9 files changed

Lines changed: 333 additions & 4 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@modelcontextprotocol/server-everything": minor
3+
---
4+
5+
Add a resource collection example: reading `demo://resource/collection/summer-specials`, or the `demo://resource/collection/by-category/{category}` template, returns several contents in one `resources/read`, each under its item's own `demo://resource/collection/item/{itemId}` URI, which reads back on its own. Both templates complete their variable.

‎src/everything/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ This MCP server attempts to exercise all the features of the MCP protocol. It is
1313

1414
A complete list of the registered MCP primitives and other protocol features demonstrated can be found in the [Server Features](docs/features.md) document.
1515

16+
The resource collections (`demo://resource/collection/summer-specials`, and the `by-category/{category}` and `item/{itemId}` templates beside it) show a single `resources/read` returning several contents, each under its own URI: reading a collection returns one content per item, and each item's URI can be read on its own.
17+
1618
The `get-env` tool returns the server's environment variables, but masks the value of any credential-like name (a `KEY`, `TOKEN`, `SECRET`, `PASSWORD`, `PASSWD`, `CREDENTIAL`, `AUTH` or `COOKIE` segment, such as `OPENAI_API_KEY`) as `"<redacted>"`. To see every value unmasked while debugging, start the server with `GET_ENV_INCLUDE_SECRETS=1` (or `true`). Leave it unset otherwise: with it set, every credential in the server's environment reaches the model's context.
1719

1820
## Protocol versions
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* Characterizes the resource collections (#3130) through the protocol: a
3+
* single `resources/read` of a collection answers with several contents, each
4+
* under its item's own URI, and each of those URIs reads back on its own. The
5+
* same reads run against a 2025-era client (`initialize`) and a 2026-07-28
6+
* client (`server/discover`), since the collections are served to both. Also
7+
* covers the category and item-id completers and the -32602 for a category or
8+
* item that is not in the catalog.
9+
*/
10+
import { afterEach, describe, expect, it } from "vitest";
11+
import { connect, connectModern, type Session } from "./harness.js";
12+
import {
13+
COLLECTION_CATEGORIES,
14+
COLLECTION_ITEMS,
15+
SUMMER_SPECIALS_URI,
16+
collectionItemUri,
17+
} from "../resources/collections.js";
18+
19+
let session: Session | undefined;
20+
21+
afterEach(async () => {
22+
await session?.close();
23+
session = undefined;
24+
});
25+
26+
/** The contents one item is served as. */
27+
const contentsOf = (item: (typeof COLLECTION_ITEMS)[number]) => ({
28+
uri: collectionItemUri(item.id),
29+
mimeType: "application/json",
30+
text: JSON.stringify(item, null, 2),
31+
});
32+
33+
const CATEGORY_URI = "demo://resource/collection/by-category";
34+
35+
describe.each([
36+
["2025-era", () => connect()],
37+
["2026-07-28", () => connectModern()],
38+
])("resource collections for a %s client", (_era, open) => {
39+
it("lists the summer-specials collection and both templates", async () => {
40+
session = await open();
41+
const { resources } = await session.client.listResources();
42+
expect(resources).toContainEqual(
43+
expect.objectContaining({
44+
name: "summer-specials",
45+
uri: SUMMER_SPECIALS_URI,
46+
mimeType: "application/json",
47+
}),
48+
);
49+
const { resourceTemplates } = await session.client.listResourceTemplates();
50+
expect(resourceTemplates.map((t) => [t.name, t.uriTemplate])).toEqual(
51+
expect.arrayContaining([
52+
["collection-by-category", `${CATEGORY_URI}/{category}`],
53+
["collection-item", "demo://resource/collection/item/{itemId}"],
54+
]),
55+
);
56+
});
57+
58+
it("returns every item as a separate content, each under its own URI", async () => {
59+
session = await open();
60+
const { contents } = await session.client.readResource({
61+
uri: SUMMER_SPECIALS_URI,
62+
});
63+
expect(contents).toEqual(COLLECTION_ITEMS.map(contentsOf));
64+
expect(new Set(contents.map((c) => c.uri)).size).toBe(
65+
COLLECTION_ITEMS.length,
66+
);
67+
});
68+
69+
it.each(COLLECTION_CATEGORIES)(
70+
"returns only the %s items for that category",
71+
async (category) => {
72+
session = await open();
73+
const { contents } = await session.client.readResource({
74+
uri: `${CATEGORY_URI}/${category}`,
75+
});
76+
expect(contents).toEqual(
77+
COLLECTION_ITEMS.filter((i) => i.category === category).map(contentsOf),
78+
);
79+
},
80+
);
81+
82+
it("reads back each member of a collection under the URI it was returned with", async () => {
83+
session = await open();
84+
const { contents } = await session.client.readResource({
85+
uri: SUMMER_SPECIALS_URI,
86+
});
87+
for (const member of contents) {
88+
const read = await session.client.readResource({ uri: member.uri });
89+
expect(read.contents).toEqual([member]);
90+
}
91+
});
92+
93+
it.each([
94+
`${CATEGORY_URI}/outerwear`,
95+
"demo://resource/collection/item/0",
96+
"demo://resource/collection/item/6",
97+
"demo://resource/collection/item/01",
98+
"demo://resource/collection/item/abc",
99+
])("answers %s, which is not in the catalog, with -32602", async (uri) => {
100+
session = await open();
101+
await expect(session.client.readResource({ uri })).rejects.toMatchObject({
102+
code: -32602,
103+
data: { uri },
104+
});
105+
});
106+
});
107+
108+
describe("completion/complete for the collection templates", () => {
109+
/** Complete one variable of a collection template. */
110+
async function complete(uri: string, name: string, value: string) {
111+
session = await connect();
112+
const { completion } = await session.client.complete({
113+
ref: { type: "ref/resource", uri },
114+
argument: { name, value },
115+
});
116+
return completion.values;
117+
}
118+
119+
it.each([
120+
["", ["accessories", "footwear", "skincare"]],
121+
["f", ["footwear"]],
122+
["x", []],
123+
])("completes category %j", async (value, expected) => {
124+
expect(
125+
await complete(`${CATEGORY_URI}/{category}`, "category", value),
126+
).toEqual(expected);
127+
});
128+
129+
it.each([
130+
["", ["1", "2", "3", "4", "5"]],
131+
["3", ["3"]],
132+
["9", []],
133+
])("completes itemId %j", async (value, expected) => {
134+
expect(
135+
await complete(
136+
"demo://resource/collection/item/{itemId}",
137+
"itemId",
138+
value,
139+
),
140+
).toEqual(expected);
141+
});
142+
});

‎src/everything/__tests__/static-files.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,9 @@ describe("static document resources", () => {
112112
const session = await harness.connect();
113113
try {
114114
const { resources } = await session.client.listResources();
115-
expect(resources).toEqual([]);
115+
expect(
116+
resources.filter((r) => r.uri.includes("/static/document/")),
117+
).toEqual([]);
116118
expect(session.client.getInstructions()).toBe(
117119
"Server instructions not loaded: Error: ENOENT: no docs",
118120
);

‎src/everything/__tests__/surface.test.ts‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -827,7 +827,7 @@ describe("prompts/list", () => {
827827
});
828828

829829
describe("resources/list and resources/templates/list", () => {
830-
it("lists one static resource per file in docs/", async () => {
830+
it("lists one static resource per file in docs/, and the summer-specials collection", async () => {
831831
session = await connect();
832832
const { resources } = await session.client.listResources();
833833
expect(resources).toMatchInlineSnapshot(`
@@ -874,11 +874,18 @@ describe("resources/list and resources/templates/list", () => {
874874
"name": "structure.md",
875875
"uri": "demo://resource/static/document/structure.md",
876876
},
877+
{
878+
"description": "A resource collection: one read returns every item in the catalog as a separate content, each under its own demo://resource/collection/item/{itemId} URI.",
879+
"mimeType": "application/json",
880+
"name": "summer-specials",
881+
"title": "Summer Specials Collection",
882+
"uri": "demo://resource/collection/summer-specials",
883+
},
877884
]
878885
`);
879886
});
880887

881-
it("lists the dynamic text and blob templates", async () => {
888+
it("lists the dynamic text and blob templates, and the collection templates", async () => {
882889
session = await connect();
883890
const { resourceTemplates } = await session.client.listResourceTemplates();
884891
expect(resourceTemplates).toMatchInlineSnapshot(`
@@ -895,6 +902,20 @@ describe("resources/list and resources/templates/list", () => {
895902
"name": "Dynamic Blob Resource",
896903
"uriTemplate": "demo://resource/dynamic/blob/{resourceId}",
897904
},
905+
{
906+
"description": "A resource collection filtered by the {category} variable (accessories, footwear, skincare): one read returns each item in that category as a separate content, under its own URI.",
907+
"mimeType": "application/json",
908+
"name": "collection-by-category",
909+
"title": "Collection by Category",
910+
"uriTemplate": "demo://resource/collection/by-category/{category}",
911+
},
912+
{
913+
"description": "One item of the resource collections, selected by the {itemId} variable (1 to 5). The collections return their items under these URIs.",
914+
"mimeType": "application/json",
915+
"name": "collection-item",
916+
"title": "Collection Item",
917+
"uriTemplate": "demo://resource/collection/item/{itemId}",
918+
},
898919
]
899920
`);
900921
});

‎src/everything/docs/features.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,9 @@ On a 2025-era connection the tools that need a client capability are registered
7070
- Static Documents: `demo://resource/static/document/<filename>` (serves files from `src/everything/docs/` as static file-based resources; a 2026-07-28 read carries a one-hour public cache hint)
7171
- Session Scoped, **2025-era**: `demo://resource/session/<name>` (per-session resources registered dynamically; available only for the lifetime of the session)
7272
- Minted, **2026-07-28**: `demo://resource/minted/<id>/<name>` (resources a tool created, under a server-minted id; readable by any client that has the URI, for ten minutes; the process keeps at most 100 of them and 64 MiB of content, dropping the oldest first)
73+
- Collections: `demo://resource/collection/summer-specials`, `demo://resource/collection/by-category/{category}` and `demo://resource/collection/item/{itemId}` (resources/collections.ts; one `resources/read` of a collection returns several contents, one per item, each under its item's own URI, which reads back on its own; `category` and `itemId` complete)
7374

74-
A read of a resource that does not exist (an unknown URI, a template id that is not a positive integer, an expired minted resource) is answered with `-32602` and the URI in the error's `data`.
75+
A read of a resource that does not exist (an unknown URI, a template id that is not a positive integer, a collection category or item that is not in the catalog, an expired minted resource) is answered with `-32602` and the URI in the error's `data`.
7576

7677
## Resource Subscriptions and Notifications
7778

‎src/everything/docs/structure.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ src/everything
2828
│ └── resource.ts
2929
├── resources
3030
│ ├── index.ts
31+
│ ├── collections.ts
3132
│ ├── files.ts
3233
│ ├── minted.ts
3334
│ ├── session.ts
@@ -132,6 +133,8 @@ src/everything
132133
- Blob: `demo://resource/dynamic/blob/{index}` (MIME: `application/octet-stream`, Base64 payload)
133134
- The `{index}` path variable must be a finite positive integer; any other is not found (`-32602`). Content is generated on demand with a timestamp.
134135
- Exposes helpers `textResource(uri, index)`, `textResourceUri(index)`, `blobResource(uri, index)`, and `blobResourceUri(index)` so other modules can construct and embed dynamic resources directly (e.g., from prompts).
136+
- `collections.ts`
137+
- Registers a resource collection example: `summer-specials` (`demo://resource/collection/summer-specials`), whose read returns every item of a small fixed catalog as a separate content under its own URI; the `collection-by-category` template (`demo://resource/collection/by-category/{category}`), which returns one category's items; and the `collection-item` template (`demo://resource/collection/item/{itemId}`), which reads one item back. Both templates complete their variable, and a category or id not in the catalog is not found (`-32602`).
135138
- `files.ts`
136139
- Registers static file-based resources for each file in the `docs/` folder.
137140
- URIs follow the pattern: `demo://resource/static/document/<filename>`.
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/**
2+
* Resource collections (#3130): one `resources/read` that answers with several
3+
* contents, each under a URI of its own. The spec allows a read to return more
4+
* than one content ("a server could return the contents of several files when
5+
* a directory resource is read"), and this module shows the pattern with a
6+
* small, fixed product catalog:
7+
*
8+
* - `demo://resource/collection/summer-specials` (static, listed): every item.
9+
* - `demo://resource/collection/by-category/{category}` (template, with a
10+
* completer for `category`): the items in one category.
11+
* - `demo://resource/collection/item/{itemId}` (template, with a completer for
12+
* `itemId`): one item.
13+
*
14+
* Every content a collection returns carries its item's URI, not the
15+
* collection's, and that URI is readable on its own through the item
16+
* template, so a client can follow up on any one member. The catalog is a
17+
* constant, so the module keeps no state and serves both protocol eras alike.
18+
* Adapted from the example in olaservo's gist linked on the issue.
19+
*/
20+
import {
21+
CompleteResourceTemplateCallback,
22+
McpServer,
23+
ResourceNotFoundError,
24+
ResourceTemplate,
25+
} from "@modelcontextprotocol/server";
26+
27+
/** One product in the example catalog. */
28+
export type CollectionItem = {
29+
id: number;
30+
name: string;
31+
price: number;
32+
category: string;
33+
};
34+
35+
/** The example catalog the collections are drawn from. */
36+
export const COLLECTION_ITEMS: readonly CollectionItem[] = [
37+
{ id: 1, name: "Summer Hat", price: 29.99, category: "accessories" },
38+
{ id: 2, name: "Beach Towel", price: 19.99, category: "accessories" },
39+
{ id: 3, name: "Sunglasses", price: 49.99, category: "accessories" },
40+
{ id: 4, name: "Flip Flops", price: 14.99, category: "footwear" },
41+
{ id: 5, name: "Sunscreen SPF50", price: 12.99, category: "skincare" },
42+
];
43+
44+
/** The distinct categories in the catalog, in first-seen order. */
45+
export const COLLECTION_CATEGORIES: readonly string[] = [
46+
...new Set(COLLECTION_ITEMS.map((item) => item.category)),
47+
];
48+
49+
const MIME_TYPE = "application/json";
50+
const collectionUriBase = "demo://resource/collection";
51+
const itemUriBase = `${collectionUriBase}/item`;
52+
const categoryUriBase = `${collectionUriBase}/by-category`;
53+
54+
/** The URI of the collection holding every item. */
55+
export const SUMMER_SPECIALS_URI = `${collectionUriBase}/summer-specials`;
56+
57+
/**
58+
* The URI under which one item can be read on its own.
59+
* @param id
60+
*/
61+
export const collectionItemUri = (id: number) => `${itemUriBase}/${id}`;
62+
63+
/**
64+
* The contents of one item: JSON, under the item's own URI.
65+
* @param item
66+
*/
67+
const itemContents = (item: CollectionItem) => ({
68+
uri: collectionItemUri(item.id),
69+
mimeType: MIME_TYPE,
70+
text: JSON.stringify(item, null, 2),
71+
});
72+
73+
/** Completes a category name from its prefix. */
74+
const categoryCompleter: CompleteResourceTemplateCallback = (value) =>
75+
COLLECTION_CATEGORIES.filter((category) => category.startsWith(value));
76+
77+
/** Completes an item id from its prefix. */
78+
const itemIdCompleter: CompleteResourceTemplateCallback = (value) =>
79+
COLLECTION_ITEMS.map((item) => String(item.id)).filter((id) =>
80+
id.startsWith(value),
81+
);
82+
83+
/**
84+
* Register the collection resources with the MCP server.
85+
* - `summer-specials`: a static resource whose read returns every item
86+
* - `collection-by-category`: a template whose read returns one category's items
87+
* - `collection-item`: a template whose read returns one item
88+
* - A category or item id that is not in the catalog is not found (`-32602`)
89+
*
90+
* @param server
91+
*/
92+
export const registerCollectionResources = (server: McpServer) => {
93+
// The whole collection: one read, one content per item, each with its own URI
94+
server.registerResource(
95+
"summer-specials",
96+
SUMMER_SPECIALS_URI,
97+
{
98+
title: "Summer Specials Collection",
99+
mimeType: MIME_TYPE,
100+
description:
101+
"A resource collection: one read returns every item in the catalog as a separate content, each under its own demo://resource/collection/item/{itemId} URI.",
102+
},
103+
async () => ({ contents: COLLECTION_ITEMS.map(itemContents) }),
104+
);
105+
106+
// A filtered collection: the items in one category
107+
server.registerResource(
108+
"collection-by-category",
109+
new ResourceTemplate(`${categoryUriBase}/{category}`, {
110+
list: undefined,
111+
complete: { category: categoryCompleter },
112+
}),
113+
{
114+
title: "Collection by Category",
115+
mimeType: MIME_TYPE,
116+
description: `A resource collection filtered by the {category} variable (${COLLECTION_CATEGORIES.join(", ")}): one read returns each item in that category as a separate content, under its own URI.`,
117+
},
118+
async (uri, variables) => {
119+
const category = String(variables.category);
120+
const items = COLLECTION_ITEMS.filter(
121+
(item) => item.category === category,
122+
);
123+
if (items.length === 0) throw new ResourceNotFoundError(uri.toString());
124+
return { contents: items.map(itemContents) };
125+
},
126+
);
127+
128+
// One member of the collections, readable on its own
129+
server.registerResource(
130+
"collection-item",
131+
new ResourceTemplate(`${itemUriBase}/{itemId}`, {
132+
list: undefined,
133+
complete: { itemId: itemIdCompleter },
134+
}),
135+
{
136+
title: "Collection Item",
137+
mimeType: MIME_TYPE,
138+
description:
139+
"One item of the resource collections, selected by the {itemId} variable (1 to 5). The collections return their items under these URIs.",
140+
},
141+
async (uri, variables) => {
142+
// Match the id's exact spelling, so "01" is not found rather than item 1
143+
const itemId = String(variables.itemId);
144+
const item = COLLECTION_ITEMS.find(
145+
(candidate) => String(candidate.id) === itemId,
146+
);
147+
if (!item) throw new ResourceNotFoundError(uri.toString());
148+
return { contents: [itemContents(item)] };
149+
},
150+
);
151+
};

0 commit comments

Comments
 (0)