Skip to content

Commit f761169

Browse files
committed
Add grouping extension implementation with tests and examples
1 parent 07e546d commit f761169

8 files changed

Lines changed: 1442 additions & 0 deletions

File tree

sdk/typescript/README.md

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
# MCP Grouping Extension — TypeScript SDK
2+
3+
Organize MCP tools, resources, and other primitives into named groups. This package implements the [Grouping Extension specification](../../specification/draft/grouping.mdx) as an add-on for the
4+
[`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk).
5+
6+
> **Status:** Experimental. The capability is registered under `experimental["io.modelcontextprotocol/grouping"]` until the SDK ships the `extensions` field on `ServerCapabilities`.
7+
8+
## Install
9+
10+
```bash
11+
npm install @anthropic/ext-grouping
12+
```
13+
14+
Peer dependency: `@modelcontextprotocol/sdk` >= 1.27.
15+
16+
## Quick start — Server
17+
18+
```typescript
19+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
20+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
21+
import { GroupingExtension, GROUPS_META_KEY } from '@anthropic/ext-grouping';
22+
import { z } from 'zod/v4';
23+
24+
const mcpServer = new McpServer({ name: 'my-server', version: '1.0.0' });
25+
const grouping = new GroupingExtension(mcpServer);
26+
27+
// Register groups
28+
grouping.registerGroup('email', {
29+
title: 'Email Tools',
30+
description: 'Tools for email workflows.'
31+
});
32+
33+
// Assign tools to groups via _meta
34+
mcpServer.registerTool(
35+
'send_email',
36+
{
37+
description: 'Send an email',
38+
inputSchema: { to: z.string(), body: z.string() },
39+
_meta: { [GROUPS_META_KEY]: ['email'] }
40+
},
41+
async ({ to, body }) => ({
42+
content: [{ type: 'text', text: `Sent to ${to}` }]
43+
})
44+
);
45+
46+
// Assign resources to groups via _meta
47+
mcpServer.registerResource(
48+
'inbox',
49+
'email://inbox',
50+
{
51+
description: 'Current inbox',
52+
_meta: { [GROUPS_META_KEY]: ['email'] }
53+
},
54+
async () => ({
55+
contents: [{ uri: 'email://inbox', text: 'No messages.', mimeType: 'text/plain' }]
56+
})
57+
);
58+
59+
const transport = new StdioServerTransport();
60+
await mcpServer.connect(transport);
61+
```
62+
63+
## Quick start — Client
64+
65+
```typescript
66+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
67+
import { GroupingClient, GROUPS_META_KEY } from '@anthropic/ext-grouping';
68+
69+
const client = new Client({ name: 'my-client', version: '1.0.0' });
70+
await client.connect(transport);
71+
72+
const groupingClient = new GroupingClient(client);
73+
74+
// List groups
75+
const { groups } = await groupingClient.listGroups();
76+
77+
// Filter tools by group
78+
const { tools } = await client.listTools();
79+
const emailTools = tools.filter(t => {
80+
const membership = GroupingClient.getGroupMembership(t._meta);
81+
return membership.includes('email');
82+
});
83+
84+
// Listen for group changes
85+
groupingClient.onGroupsChanged(async () => {
86+
const updated = await groupingClient.listGroups();
87+
console.log('Groups updated:', updated.groups);
88+
});
89+
```
90+
91+
## Nested groups
92+
93+
Groups can belong to other groups via the same `_meta` key:
94+
95+
```typescript
96+
grouping.registerGroup('productivity', {
97+
title: 'Productivity Suite'
98+
});
99+
100+
grouping.registerGroup('email', {
101+
title: 'Email Tools',
102+
_meta: { [GROUPS_META_KEY]: ['productivity'] }
103+
});
104+
105+
grouping.registerGroup('calendar', {
106+
title: 'Calendar Tools',
107+
_meta: { [GROUPS_META_KEY]: ['productivity'] }
108+
});
109+
```
110+
111+
A client can then expand a parent group into all its descendants:
112+
113+
```typescript
114+
const { groups } = await groupingClient.listGroups();
115+
116+
// Build parent → children map
117+
const children = new Map<string, string[]>();
118+
for (const g of groups) {
119+
for (const parent of GroupingClient.getGroupMembership(g._meta)) {
120+
if (!children.has(parent)) children.set(parent, []);
121+
children.get(parent)!.push(g.name);
122+
}
123+
}
124+
125+
// BFS to collect all descendant group names
126+
function expand(name: string): Set<string> {
127+
const visited = new Set<string>();
128+
const queue = [name];
129+
while (queue.length) {
130+
const cur = queue.shift()!;
131+
if (visited.has(cur)) continue;
132+
visited.add(cur);
133+
for (const child of children.get(cur) ?? []) queue.push(child);
134+
}
135+
return visited;
136+
}
137+
138+
const allProductivity = expand('productivity');
139+
// Set { "productivity", "email", "calendar" }
140+
```
141+
142+
## API reference
143+
144+
### `GroupingExtension` (server)
145+
146+
```typescript
147+
import { GroupingExtension } from '@anthropic/ext-grouping';
148+
149+
const grouping = new GroupingExtension(mcpServer);
150+
```
151+
152+
| Method | Description |
153+
| ------------------------------ | ----------------------------------------------------------------- |
154+
| `registerGroup(name, config?)` | Register a group. Returns a `RegisteredGroup` handle. |
155+
| `removeGroup(name)` | Remove a group by name. |
156+
| `sendGroupListChanged()` | Manually send a `notifications/groups/list_changed` notification. |
157+
158+
**`RegisteredGroup`** handle returned by `registerGroup`:
159+
160+
| Property / Method | Description |
161+
| ------------------------------------------------------- | --------------------------------------------------- |
162+
| `title`, `description`, `icons`, `annotations`, `_meta` | Group metadata (read/write). |
163+
| `enabled` | Whether the group appears in `groups/list` results. |
164+
| `enable()` / `disable()` | Toggle visibility. |
165+
| `update(updates)` | Batch-update fields (including rename via `name`). |
166+
| `remove()` | Remove the group. |
167+
168+
### `GroupingClient` (client)
169+
170+
```typescript
171+
import { GroupingClient } from '@anthropic/ext-grouping';
172+
173+
const groupingClient = new GroupingClient(client);
174+
```
175+
176+
| Method | Description |
177+
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
178+
| `listGroups(params?)` | Send `groups/list` request. Accepts `{ cursor?: string }` for pagination. |
179+
| `onGroupsChanged(handler)` | Register a callback for `notifications/groups/list_changed`. |
180+
| `GroupingClient.getGroupMembership(meta)` | Static utility — extracts the `string[]` of group names from a primitive's `_meta`. Returns `[]` if absent. |
181+
182+
### Constants and types
183+
184+
| Export | Description |
185+
| ------------------------------------ | ----------------------------------------------------------------------------------- |
186+
| `GROUPS_META_KEY` | `"io.modelcontextprotocol/groups"` — the reserved `_meta` key for group membership. |
187+
| `GROUPING_EXTENSION_ID` | `"io.modelcontextprotocol/grouping"` — the canonical extension identifier. |
188+
| `GroupSchema` | Zod schema for a `Group` object. |
189+
| `ListGroupsRequestSchema` | Zod schema for the `groups/list` request. |
190+
| `ListGroupsResultSchema` | Zod schema for the `groups/list` response. |
191+
| `GroupListChangedNotificationSchema` | Zod schema for `notifications/groups/list_changed`. |
192+
| `Group`, `ListGroupsResult`, … | TypeScript type aliases inferred from the above schemas. |
193+
194+
## Capability registration
195+
196+
The extension registers its capability at:
197+
198+
```json
199+
{
200+
"experimental": {
201+
"io.modelcontextprotocol/grouping": {
202+
"listChanged": true
203+
}
204+
}
205+
}
206+
```
207+
208+
When the MCP SDK adds the `extensions` field to `ServerCapabilities`, this will move to `capabilities.extensions["io.modelcontextprotocol/grouping"]`.
209+
210+
## Running the examples
211+
212+
```bash
213+
cd sdk/typescript
214+
npm install
215+
216+
# Run the example server (stdio)
217+
npx tsx examples/server.ts
218+
219+
# Run the interactive example client (spawns the server automatically)
220+
npx tsx examples/client.ts
221+
```
222+
223+
## Development
224+
225+
```bash
226+
npm install
227+
npm run build # Compile TypeScript
228+
npm test # Run tests (vitest)
229+
npm run test:watch # Watch mode
230+
```

sdk/typescript/examples/client.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/**
2+
* Example MCP client for the Grouping extension.
3+
*
4+
* Connects to the example server, lists groups and tools, and demonstrates
5+
* filtering tools by group membership with nested group traversal.
6+
*
7+
* Run: npx tsx examples/client.ts
8+
*/
9+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
10+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
11+
import { GroupingClient, GROUPS_META_KEY } from '../src/index.js';
12+
import type { Group } from '../src/index.js';
13+
import * as readline from 'node:readline';
14+
15+
async function main() {
16+
const transport = new StdioClientTransport({
17+
command: 'npx',
18+
args: ['tsx', 'examples/server.ts']
19+
});
20+
21+
const client = new Client({
22+
name: 'groups-example-client',
23+
version: '1.0.0'
24+
});
25+
26+
const groupingClient = new GroupingClient(client);
27+
28+
await client.connect(transport);
29+
console.log('Connected to groups example server\n');
30+
31+
// Fetch all data
32+
const { groups } = await groupingClient.listGroups();
33+
const { tools } = await client.listTools();
34+
const { resources } = await client.listResources();
35+
36+
// Listen for group changes
37+
groupingClient.onGroupsChanged(async () => {
38+
const updated = await groupingClient.listGroups();
39+
console.log(
40+
'\n[Groups changed]',
41+
updated.groups.map(g => g.name)
42+
);
43+
});
44+
45+
// Build parent→children adjacency map from _meta membership
46+
const parentToChildren = new Map<string, Set<string>>();
47+
for (const group of groups) {
48+
const parents = GroupingClient.getGroupMembership(group._meta);
49+
for (const parent of parents) {
50+
if (!parentToChildren.has(parent)) {
51+
parentToChildren.set(parent, new Set());
52+
}
53+
parentToChildren.get(parent)!.add(group.name);
54+
}
55+
}
56+
57+
// BFS expansion: given a group name, find all descendant group names
58+
function expandGroup(name: string, maxDepth = 10): Set<string> {
59+
const visited = new Set<string>();
60+
const queue: [string, number][] = [[name, 0]];
61+
while (queue.length > 0) {
62+
const [current, depth] = queue.shift()!;
63+
if (visited.has(current) || depth > maxDepth) continue;
64+
visited.add(current);
65+
const children = parentToChildren.get(current);
66+
if (children) {
67+
for (const child of children) {
68+
queue.push([child, depth + 1]);
69+
}
70+
}
71+
}
72+
return visited;
73+
}
74+
75+
// Filter tools belonging to a set of group names
76+
function filterByGroups<T extends { _meta?: Record<string, unknown> }>(items: T[], groupNames: Set<string>): T[] {
77+
return items.filter(item => {
78+
const membership = GroupingClient.getGroupMembership(item._meta);
79+
return membership.some(g => groupNames.has(g));
80+
});
81+
}
82+
83+
// Print helpers
84+
function printGroups(gs: Group[]) {
85+
for (const g of gs) {
86+
const parents = GroupingClient.getGroupMembership(g._meta);
87+
const parentStr = parents.length > 0 ? ` (in: ${parents.join(', ')})` : '';
88+
console.log(` [${g.name}] ${g.title ?? g.name}${parentStr}`);
89+
if (g.description) console.log(` ${g.description}`);
90+
}
91+
}
92+
93+
// Interactive REPL
94+
const rl = readline.createInterface({
95+
input: process.stdin,
96+
output: process.stdout
97+
});
98+
99+
console.log('Commands:');
100+
console.log(' all — show all groups, tools, resources');
101+
console.log(' groups — list groups');
102+
console.log(' <name> — filter tools/resources by group (with nesting)');
103+
console.log(' help — show commands');
104+
console.log(' exit — quit\n');
105+
106+
const prompt = () => rl.question('> ', handleCommand);
107+
108+
function handleCommand(input: string) {
109+
const cmd = input.trim().toLowerCase();
110+
111+
if (cmd === 'exit' || cmd === 'quit') {
112+
rl.close();
113+
client.close();
114+
return;
115+
}
116+
117+
if (cmd === 'help') {
118+
console.log(' all, groups, <group-name>, help, exit');
119+
} else if (cmd === 'all') {
120+
console.log('\n--- Groups ---');
121+
printGroups(groups);
122+
console.log(`\n--- Tools (${tools.length}) ---`);
123+
for (const t of tools) {
124+
const membership = GroupingClient.getGroupMembership(t._meta);
125+
console.log(` ${t.name}${membership.length > 0 ? ` [${membership.join(', ')}]` : ''}`);
126+
}
127+
console.log(`\n--- Resources (${resources.length}) ---`);
128+
for (const r of resources) {
129+
const membership = GroupingClient.getGroupMembership(r._meta);
130+
console.log(` ${r.name}${membership.length > 0 ? ` [${membership.join(', ')}]` : ''}`);
131+
}
132+
} else if (cmd === 'groups') {
133+
console.log('\n--- Groups ---');
134+
printGroups(groups);
135+
} else {
136+
// Try to match as a group name
137+
const matchedGroup = groups.find(g => g.name === cmd || g.title?.toLowerCase() === cmd);
138+
if (matchedGroup) {
139+
const expanded = expandGroup(matchedGroup.name);
140+
const matchedTools = filterByGroups(tools, expanded);
141+
const matchedResources = filterByGroups(resources, expanded);
142+
143+
console.log(`\nGroup: ${matchedGroup.title ?? matchedGroup.name} (expanded: ${[...expanded].join(', ')})`);
144+
console.log(` Tools (${matchedTools.length}):`);
145+
for (const t of matchedTools) {
146+
console.log(` ${t.name}${t.description}`);
147+
}
148+
console.log(` Resources (${matchedResources.length}):`);
149+
for (const r of matchedResources) {
150+
console.log(` ${r.name}${r.description}`);
151+
}
152+
} else {
153+
console.log(`Unknown command or group: "${cmd}". Type "help".`);
154+
}
155+
}
156+
157+
console.log();
158+
prompt();
159+
}
160+
161+
prompt();
162+
}
163+
164+
main().catch(console.error);

0 commit comments

Comments
 (0)