From 71c6c3c22487897a46f82462b3b5419d88acdb1f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 16 Jan 2026 17:21:56 -0500 Subject: [PATCH 01/11] Implemented the new `groups` primitive across the MCP TypeScript SDK, allowing servers to organize primitives (tools, prompts, tasks, resources, and groups themselves) into logical groups. This change includes updates to the core protocol schemas, high-level server and client APIs, and comprehensive testing. ### PR Description: Implementation of Groups Primitive This PR introduces the `groups` primitive to the Model Context Protocol TypeScript SDK, enabling servers to categorize and organize their offerings. #### Core Changes (`@modelcontextprotocol/core`) **`src/types/types.ts`** - Added `GROUPS_META_KEY` constant (`io.modelcontextprotocol/groups`). - Updated `BaseMetadataSchema` to include `name` and optional `title`. - Introduced `GroupSchema` and `Group` type for defining group objects. - Added `ServerCapabilitiesSchema.groups` to allow servers to advertise group support and `listChanged` notifications. - Added `ListGroupsRequestSchema`, `ListGroupsResultSchema`, and `GroupListChangedNotificationSchema` for protocol-level group management. - Updated `ToolSchema`, `PromptSchema`, `ResourceSchema`, and `TaskSchema` to support the `groups` key in their `_meta` objects. - Added `ListChangedHandlers` support for groups in client-side configuration. **`src/shared/protocol.ts`** - Added `listGroups` method to the base `Protocol` class to handle `groups/list` requests. #### Server Changes (`@modelcontextprotocol/server`) **`src/server/server.ts`** - Added `sendGroupListChanged()` method to the `Server` class to emit `notifications/groups/list_changed` to connected clients. **`src/server/mcp.ts`** - Added `registerGroup()` to the high-level `McpServer` API. - Implemented automatic handling of `groups/list` requests. - Updated `registerTool`, `registerPrompt`, and `registerResource` to correctly propagate `_meta` information (including group memberships) to the client. - Modified registration logic to be "connection-aware": capabilities and request handlers are registered before connection, while `list_changed` notifications are sent if changes occur post-connection. - Improved `update()` methods for registered primitives to handle dynamic name changes and metadata updates. #### Client Changes (`@modelcontextprotocol/client`) **`src/client/client.ts`** - Added `listGroups()` method to fetch the list of groups from a server. - Integrated `groups` into the `listChanged` notification handling system, allowing clients to automatically refresh group lists when the server notifies of changes. #### Testing **`packages/server/test/server/groups.test.ts` (New)** - Verified group registration and listing. - Verified mixed membership: tools, prompts, resources, and task-tools assigned to the same group. - Verified nested groups (groups within groups). - Verified that `_meta.groups` is only present in listed primitives when they are actually assigned to a group. **`test/integration/test/client/client.test.ts`** - Added integration tests for `groups/list` and `notifications/groups/list_changed`. - Ensured proper synchronization between server registration and client-side auto-refresh logic. --- packages/client/src/client/client.ts | 17 ++ packages/core/src/shared/protocol.ts | 12 ++ packages/core/src/types/types.ts | 152 +++++++++++++--- packages/server/src/server/mcp.ts | 173 +++++++++++++++++- packages/server/src/server/server.ts | 7 + packages/server/test/server/groups.test.ts | 185 ++++++++++++++++++++ test/integration/test/client/client.test.ts | 117 ++++++++++++- 7 files changed, 627 insertions(+), 36 deletions(-) create mode 100644 packages/server/test/server/groups.test.ts diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 8d96ba0bc..17f44166b 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -14,6 +14,7 @@ import type { jsonSchemaValidator, ListChangedHandlers, ListChangedOptions, + ListGroupsRequest, ListPromptsRequest, ListResourcesRequest, ListResourceTemplatesRequest, @@ -50,10 +51,12 @@ import { ErrorCode, getObjectShape, GetPromptResultSchema, + GroupListChangedNotificationSchema, InitializeResultSchema, isZ4Schema, LATEST_PROTOCOL_VERSION, ListChangedOptionsBaseSchema, + ListGroupsResultSchema, ListPromptsResultSchema, ListResourcesResultSchema, ListResourceTemplatesResultSchema, @@ -297,6 +300,13 @@ export class Client< return result.resources; }); } + + if (config.groups && this._serverCapabilities?.groups?.listChanged) { + this._setupListChangedHandler('groups', GroupListChangedNotificationSchema, config.groups, async () => { + const result = await this.listGroups(); + return result.groups; + }); + } } /** @@ -712,6 +722,13 @@ export class Client< return this.request({ method: 'resources/list', params }, ListResourcesResultSchema, options); } + /** + * Lists groups offered by the server. + */ + override async listGroups(params?: ListGroupsRequest['params'], options?: RequestOptions) { + return this.request({ method: 'groups/list', params }, ListGroupsResultSchema, options); + } + async listResourceTemplates(params?: ListResourceTemplatesRequest['params'], options?: RequestOptions) { return this.request({ method: 'resources/templates/list', params }, ListResourceTemplatesResultSchema, options); } diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 90c6116e0..a77e90172 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -41,6 +41,7 @@ import { isJSONRPCRequest, isJSONRPCResultResponse, isTaskAugmentedRequestParams, + ListGroupsResultSchema, ListTasksRequestSchema, ListTasksResultSchema, McpError, @@ -1266,6 +1267,17 @@ export abstract class Protocol> { + // @ts-expect-error SendRequestT cannot directly contain ListGroupsRequest, but we ensure all type instantiations contain it anyway + return this.request({ method: 'groups/list', params }, ListGroupsResultSchema, options); + } + /** * Cancels a specific task. * diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index f3e1b92a8..9e0ae99db 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -5,10 +5,36 @@ export const DEFAULT_NEGOTIATED_PROTOCOL_VERSION = '2025-03-26'; export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION, '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07']; export const RELATED_TASK_META_KEY = 'io.modelcontextprotocol/related-task'; +export const GROUPS_META_KEY = 'io.modelcontextprotocol/groups'; /* JSON-RPC types */ export const JSONRPC_VERSION = '2.0'; +/** + * The sender or recipient of messages and data in a conversation. + */ +export const RoleSchema = z.enum(['user', 'assistant']); + +/** + * Optional annotations providing clients additional context about a resource. + */ +export const AnnotationsSchema = z.object({ + /** + * Intended audience(s) for the resource. + */ + audience: z.array(RoleSchema).optional(), + + /** + * Importance hint for the resource, from 0 (least) to 1 (most). + */ + priority: z.number().min(0).max(1).optional(), + + /** + * ISO 8601 timestamp for the most recent modification. + */ + lastModified: z.iso.datetime({ offset: true }).optional() +}); + /** * Information about a validated access token, provided to request handlers. */ @@ -415,6 +441,35 @@ export const ImplementationSchema = BaseMetadataSchema.extend({ description: z.string().optional() }); +/** + * A named collection of MCP primitives. + */ +export const GroupSchema = z.object({ + ...BaseMetadataSchema.shape, + ...IconsSchema.shape, + /** + * A human-readable description of the group. + */ + description: z.string().optional(), + /** + * Optional additional group information. + */ + annotations: AnnotationsSchema.optional(), + + /** + * Optional _meta object that can contain protocol-reserved and custom fields. + */ + _meta: z + .object({ + /** + * A list of group names containing this primitive. + */ + [GROUPS_META_KEY]: z.array(z.string()).optional() + }) + .catchall(z.unknown()) + .optional() +}); + const FormElicitationCapabilitySchema = z.intersection( z.object({ applyDefaults: z.boolean().optional() @@ -623,6 +678,17 @@ export const ServerCapabilitiesSchema = z.object({ listChanged: z.boolean().optional() }) .optional(), + /** + * Present if the server offers any groups. + */ + groups: z + .object({ + /** + * Whether this server supports issuing notifications for changes to the group list. + */ + listChanged: z.boolean().optional() + }) + .optional(), /** * Present if the server supports task creation. */ @@ -751,7 +817,13 @@ export const TaskSchema = z.object({ /** * Optional diagnostic message for failed tasks or other status information. */ - statusMessage: z.optional(z.string()) + statusMessage: z.optional(z.string()), + + _meta: z.optional( + z.looseObject({ + [GROUPS_META_KEY]: z.array(z.string()).optional() + }) + ) }); /** @@ -836,6 +908,28 @@ export const CancelTaskRequestSchema = RequestSchema.extend({ */ export const CancelTaskResultSchema = ResultSchema.merge(TaskSchema); +/** + * Sent from the client to request a list of groups the server has. + */ +export const ListGroupsRequestSchema = PaginatedRequestSchema.extend({ + method: z.literal('groups/list') +}); + +/** + * The server's response to a groups/list request from the client. + */ +export const ListGroupsResultSchema = PaginatedResultSchema.extend({ + groups: z.array(GroupSchema) +}); + +/** + * An optional notification from the server to the client, informing it that the list of groups it offers has changed. Servers may issue this without any previous subscription from the client. + */ +export const GroupListChangedNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/groups/list_changed'), + params: NotificationsParamsSchema.optional() +}); + /* Resources */ /** * The contents of a specific resource or sub-resource. @@ -889,31 +983,6 @@ export const BlobResourceContentsSchema = ResourceContentsSchema.extend({ blob: Base64Schema }); -/** - * The sender or recipient of messages and data in a conversation. - */ -export const RoleSchema = z.enum(['user', 'assistant']); - -/** - * Optional annotations providing clients additional context about a resource. - */ -export const AnnotationsSchema = z.object({ - /** - * Intended audience(s) for the resource. - */ - audience: z.array(RoleSchema).optional(), - - /** - * Importance hint for the resource, from 0 (least) to 1 (most). - */ - priority: z.number().min(0).max(1).optional(), - - /** - * ISO 8601 timestamp for the most recent modification. - */ - lastModified: z.iso.datetime({ offset: true }).optional() -}); - /** * A known resource that the server is capable of reading. */ @@ -946,7 +1015,11 @@ export const ResourceSchema = z.object({ * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) * for notes on _meta usage. */ - _meta: z.optional(z.looseObject({})) + _meta: z.optional( + z.looseObject({ + [GROUPS_META_KEY]: z.array(z.string()).optional() + }) + ) }); /** @@ -1122,7 +1195,11 @@ export const PromptSchema = z.object({ * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) * for notes on _meta usage. */ - _meta: z.optional(z.looseObject({})) + _meta: z.optional( + z.looseObject({ + [GROUPS_META_KEY]: z.array(z.string()).optional() + }) + ) }); /** @@ -1443,7 +1520,11 @@ export const ToolSchema = z.object({ * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) * for notes on _meta usage. */ - _meta: z.record(z.string(), z.unknown()).optional() + _meta: z.optional( + z.looseObject({ + [GROUPS_META_KEY]: z.array(z.string()).optional() + }) + ) }); /** @@ -1613,6 +1694,10 @@ export type ListChangedHandlers = { * Handler for resource list changes. */ resources?: ListChangedOptions; + /** + * Handler for group list changes. + */ + groups?: ListChangedOptions; }; /* Logging */ @@ -2261,6 +2346,7 @@ export const ClientRequestSchema = z.union([ UnsubscribeRequestSchema, CallToolRequestSchema, ListToolsRequestSchema, + ListGroupsRequestSchema, GetTaskRequestSchema, GetTaskPayloadRequestSchema, ListTasksRequestSchema, @@ -2306,6 +2392,7 @@ export const ServerNotificationSchema = z.union([ ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema, PromptListChangedNotificationSchema, + GroupListChangedNotificationSchema, TaskStatusNotificationSchema, ElicitationCompleteNotificationSchema ]); @@ -2321,6 +2408,7 @@ export const ServerResultSchema = z.union([ ReadResourceResultSchema, CallToolResultSchema, ListToolsResultSchema, + ListGroupsResultSchema, GetTaskResultSchema, ListTasksResultSchema, CreateTaskResultSchema @@ -2635,6 +2723,12 @@ export type ListRootsRequest = Infer; export type ListRootsResult = Infer; export type RootsListChangedNotification = Infer; +/* Groups */ +export type Group = Infer; +export type ListGroupsRequest = Infer; +export type ListGroupsResult = Infer; +export type GroupListChangedNotification = Infer; + /* Client messages */ export type ClientRequest = Infer; export type ClientNotification = Infer; diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 8564212c1..65e293f47 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -9,7 +9,9 @@ import type { CompleteResult, CreateTaskResult, GetPromptResult, + Group, Implementation, + ListGroupsResult, ListPromptsResult, ListResourcesResult, ListToolsResult, @@ -45,6 +47,7 @@ import { GetPromptRequestSchema, getSchemaDescription, isSchemaOptional, + ListGroupsRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, @@ -83,6 +86,7 @@ export class McpServer { } = {}; private _registeredTools: { [name: string]: RegisteredTool } = {}; private _registeredPrompts: { [name: string]: RegisteredPrompt } = {}; + private _registeredGroups: { [name: string]: RegisteredGroup } = {}; private _experimental?: { tasks: ExperimentalMcpServerTasks }; constructor(serverInfo: Implementation, options?: ServerOptions) { @@ -483,6 +487,7 @@ export class McpServer { } private _resourceHandlersInitialized = false; + private _groupHandlersInitialized = false; private setResourceRequestHandlers() { if (this._resourceHandlersInitialized) { @@ -565,6 +570,39 @@ export class McpServer { private _promptHandlersInitialized = false; + private setGroupRequestHandlers() { + if (this._groupHandlersInitialized) { + return; + } + + this.server.assertCanSetRequestHandler(getMethodValue(ListGroupsRequestSchema)); + + this.server.registerCapabilities({ + groups: { + listChanged: true + } + }); + + this.server.setRequestHandler(ListGroupsRequestSchema, (): ListGroupsResult => { + return { + groups: Object.entries(this._registeredGroups) + .filter(([, group]) => group.enabled) + .map( + ([name, group]): Group => ({ + name, + title: group.title, + description: group.description, + icons: group.icons, + annotations: group.annotations, + _meta: group._meta + }) + ) + }; + }); + + this._groupHandlersInitialized = true; + } + private setPromptRequestHandlers() { if (this._promptHandlersInitialized) { return; @@ -589,7 +627,8 @@ export class McpServer { name, title: prompt.title, description: prompt.description, - arguments: prompt.argsSchema ? promptArgumentsFromSchema(prompt.argsSchema) : undefined + arguments: prompt.argsSchema ? promptArgumentsFromSchema(prompt.argsSchema) : undefined, + _meta: prompt._meta }; }) }) @@ -804,6 +843,7 @@ export class McpServer { if (typeof updates.name !== 'undefined' && updates.name !== name) { delete this._registeredResourceTemplates[name]; if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate; + name = updates.name ?? name; } if (typeof updates.title !== 'undefined') registeredResourceTemplate.title = updates.title; if (typeof updates.template !== 'undefined') registeredResourceTemplate.resourceTemplate = updates.template; @@ -830,12 +870,14 @@ export class McpServer { title: string | undefined, description: string | undefined, argsSchema: PromptArgsRawShape | undefined, + _meta: Record | undefined, callback: PromptCallback ): RegisteredPrompt { const registeredPrompt: RegisteredPrompt = { title, description, argsSchema: argsSchema === undefined ? undefined : objectFromShape(argsSchema), + _meta, callback, enabled: true, disable: () => registeredPrompt.update({ enabled: false }), @@ -845,11 +887,13 @@ export class McpServer { if (typeof updates.name !== 'undefined' && updates.name !== name) { delete this._registeredPrompts[name]; if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt; + name = updates.name ?? name; } if (typeof updates.title !== 'undefined') registeredPrompt.title = updates.title; if (typeof updates.description !== 'undefined') registeredPrompt.description = updates.description; if (typeof updates.argsSchema !== 'undefined') registeredPrompt.argsSchema = objectFromShape(updates.argsSchema); if (typeof updates.callback !== 'undefined') registeredPrompt.callback = updates.callback; + if (typeof updates._meta !== 'undefined') registeredPrompt._meta = updates._meta; if (typeof updates.enabled !== 'undefined') registeredPrompt.enabled = updates.enabled; this.sendPromptListChanged(); } @@ -870,6 +914,56 @@ export class McpServer { return registeredPrompt; } + private _createRegisteredGroup( + name: string, + title: string | undefined, + description: string | undefined, + icons: Group['icons'] | undefined, + annotations: Group['annotations'] | undefined, + _meta: Group['_meta'] | undefined + ): RegisteredGroup { + const registeredGroup: RegisteredGroup = { + title, + description, + icons, + annotations, + _meta, + enabled: true, + disable: () => registeredGroup.update({ enabled: false }), + enable: () => registeredGroup.update({ enabled: true }), + remove: () => registeredGroup.update({ name: null }), + update: updates => { + if (typeof updates.name !== 'undefined' && updates.name !== name) { + delete this._registeredGroups[name]; + if (updates.name) this._registeredGroups[updates.name] = registeredGroup; + name = updates.name ?? name; + } + if (typeof updates.title !== 'undefined') registeredGroup.title = updates.title; + if (typeof updates.description !== 'undefined') registeredGroup.description = updates.description; + if (typeof updates.icons !== 'undefined') registeredGroup.icons = updates.icons; + if (typeof updates.annotations !== 'undefined') registeredGroup.annotations = updates.annotations; + if (typeof updates._meta !== 'undefined') registeredGroup._meta = updates._meta; + if (typeof updates.enabled !== 'undefined') registeredGroup.enabled = updates.enabled; + + if (updates.name === null) { + delete this._registeredGroups[name]; + } + + if (this.isConnected()) { + this.sendGroupListChanged(); + } + } + }; + + this._registeredGroups[name] = registeredGroup; + if (this.isConnected()) { + this.sendGroupListChanged(); + } else { + this.setGroupRequestHandlers(); + } + return registeredGroup; + } + private _createRegisteredTool( name: string, title: string | undefined, @@ -904,6 +998,7 @@ export class McpServer { } delete this._registeredTools[name]; if (updates.name) this._registeredTools[updates.name] = registeredTool; + name = updates.name ?? name; } if (typeof updates.title !== 'undefined') registeredTool.title = updates.title; if (typeof updates.description !== 'undefined') registeredTool.description = updates.description; @@ -913,13 +1008,19 @@ export class McpServer { if (typeof updates.annotations !== 'undefined') registeredTool.annotations = updates.annotations; if (typeof updates._meta !== 'undefined') registeredTool._meta = updates._meta; if (typeof updates.enabled !== 'undefined') registeredTool.enabled = updates.enabled; - this.sendToolListChanged(); + + if (this.isConnected()) { + this.sendToolListChanged(); + } } }; this._registeredTools[name] = registeredTool; - this.setToolRequestHandlers(); - this.sendToolListChanged(); + if (this.isConnected()) { + this.sendToolListChanged(); + } else { + this.setToolRequestHandlers(); + } return registeredTool; } @@ -1126,7 +1227,7 @@ export class McpServer { } const cb = rest[0] as PromptCallback; - const registeredPrompt = this._createRegisteredPrompt(name, undefined, description, argsSchema, cb); + const registeredPrompt = this._createRegisteredPrompt(name, undefined, description, argsSchema, undefined, cb); this.setPromptRequestHandlers(); this.sendPromptListChanged(); @@ -1143,6 +1244,7 @@ export class McpServer { title?: string; description?: string; argsSchema?: Args; + _meta?: Record; }, cb: PromptCallback ): RegisteredPrompt { @@ -1150,13 +1252,14 @@ export class McpServer { throw new Error(`Prompt ${name} is already registered`); } - const { title, description, argsSchema } = config; + const { title, description, argsSchema, _meta } = config; const registeredPrompt = this._createRegisteredPrompt( name, title, description, argsSchema, + _meta, cb as PromptCallback ); @@ -1166,6 +1269,29 @@ export class McpServer { return registeredPrompt; } + registerGroup( + name: string, + config: { + title?: string; + description?: string; + icons?: Group['icons']; + annotations?: Group['annotations']; + _meta?: Group['_meta']; + } = {} + ): RegisteredGroup { + if (this._registeredGroups[name]) { + throw new Error(`Group ${name} is already registered`); + } + + const { title, description, icons, annotations, _meta } = config; + + const registeredGroup = this._createRegisteredGroup(name, title, description, icons, annotations, _meta); + + this.sendGroupListChanged(); + + return registeredGroup; + } + /** * Checks if the server is connected to a transport. * @returns True if the server is connected @@ -1210,6 +1336,15 @@ export class McpServer { this.server.sendPromptListChanged(); } } + + /** + * Sends a group list changed event to the client, if connected. + */ + sendGroupListChanged() { + if (this.isConnected()) { + this.server.sendGroupListChanged(); + } + } } /** @@ -1479,6 +1614,7 @@ export type RegisteredPrompt = { title?: string; description?: string; argsSchema?: AnyObjectSchema; + _meta?: Record; callback: PromptCallback; enabled: boolean; enable(): void; @@ -1488,12 +1624,37 @@ export type RegisteredPrompt = { title?: string; description?: string; argsSchema?: Args; + _meta?: Record; callback?: PromptCallback; enabled?: boolean; }): void; remove(): void; }; +/** + * A group that has been registered with the server. + */ +export type RegisteredGroup = { + title?: string; + description?: string; + icons?: Group['icons']; + annotations?: Group['annotations']; + _meta?: Group['_meta']; + enabled: boolean; + enable(): void; + disable(): void; + update(updates: { + name?: string | null; + title?: string; + description?: string; + icons?: Group['icons']; + annotations?: Group['annotations']; + _meta?: Group['_meta']; + enabled?: boolean; + }): void; + remove(): void; +}; + function promptArgumentsFromSchema(schema: AnyObjectSchema): PromptArgument[] { const shape = getObjectShape(schema); if (!shape) return []; diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 8132e342b..2887f0910 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -673,4 +673,11 @@ export class Server< async sendPromptListChanged() { return this.notification({ method: 'notifications/prompts/list_changed' }); } + + /** + * Sends a notification to the client that the group list has changed. + */ + async sendGroupListChanged() { + return this.notification({ method: 'notifications/groups/list_changed' }); + } } diff --git a/packages/server/test/server/groups.test.ts b/packages/server/test/server/groups.test.ts new file mode 100644 index 000000000..20d92f646 --- /dev/null +++ b/packages/server/test/server/groups.test.ts @@ -0,0 +1,185 @@ +import { Client } from '../../../client/src/index.js'; +import { GROUPS_META_KEY, InMemoryTransport } from '../../../core/src/index.js'; +import { McpServer } from '../../src/index.js'; + +describe('Server Groups', () => { + let server: McpServer; + let client: Client; + let serverTransport: InMemoryTransport; + let clientTransport: InMemoryTransport; + + beforeEach(async () => { + server = new McpServer({ + name: 'test-server', + version: '1.0.0' + }); + + const [ct, st] = InMemoryTransport.createLinkedPair(); + clientTransport = ct; + serverTransport = st; + + client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + }); + + afterEach(async () => { + await Promise.all([client.close(), server.close()]); + }); + + test('should register groups and list them', async () => { + server.registerGroup('group1', { + title: 'Group 1', + description: 'First test group' + }); + + server.registerGroup('group2', { + title: 'Group 2', + description: 'Second test group' + }); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await client.listGroups(); + expect(result.groups).toHaveLength(2); + expect(result.groups.find(g => g.name === 'group1')).toMatchObject({ + name: 'group1', + title: 'Group 1', + description: 'First test group' + }); + expect(result.groups.find(g => g.name === 'group2')).toMatchObject({ + name: 'group2', + title: 'Group 2', + description: 'Second test group' + }); + }); + + test('should add tools, prompts, resources, and tasks to groups (mixed fashion)', async () => { + server.registerGroup('mixed-group', { + description: 'A group with different primitives' + }); + + // Add tools to the group + server.registerTool( + 'tool1', + { + description: 'Test tool 1', + _meta: { + [GROUPS_META_KEY]: ['mixed-group'] + } + }, + async () => ({ content: [{ type: 'text', text: 'hi' }] }) + ); + + server.registerTool('tool-no-group', { description: 'Tool with no group' }, async () => ({ + content: [{ type: 'text', text: 'hi' }] + })); + + // Add a prompt to the same group + server.registerPrompt( + 'prompt1', + { + description: 'Test prompt 1', + _meta: { + [GROUPS_META_KEY]: ['mixed-group'] + } + }, + async () => ({ messages: [] }) + ); + + server.registerPrompt('prompt-no-group', { description: 'Prompt with no group' }, async () => ({ messages: [] })); + + // Add a resource to the same group + server.registerResource( + 'resource1', + 'test://resource1', + { + description: 'Test resource 1', + _meta: { + [GROUPS_META_KEY]: ['mixed-group'] + } + }, + async () => ({ contents: [] }) + ); + + server.registerResource('resource-no-group', 'test://resource-no-group', { description: 'Resource with no group' }, async () => ({ + contents: [] + })); + + // Add a task tool to the same group + server.experimental.tasks.registerToolTask( + 'task-tool1', + { + description: 'Test task tool 1', + _meta: { + [GROUPS_META_KEY]: ['mixed-group'] + } + }, + { + createTask: async () => { + throw new Error('not implemented'); + }, + getTask: async () => { + throw new Error('not implemented'); + }, + getTaskResult: async () => { + throw new Error('not implemented'); + } + } + ); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Verify tools (including task tool) + const toolsResult = await client.listTools(); + const tool1 = toolsResult.tools.find(t => t.name === 'tool1'); + const toolNoGroup = toolsResult.tools.find(t => t.name === 'tool-no-group'); + const taskTool1 = toolsResult.tools.find(t => t.name === 'task-tool1'); + + expect(tool1?._meta?.[GROUPS_META_KEY]).toEqual(['mixed-group']); + expect(taskTool1?._meta?.[GROUPS_META_KEY]).toEqual(['mixed-group']); + expect(toolNoGroup?._meta?.[GROUPS_META_KEY]).toBeUndefined(); + + if (toolNoGroup?._meta) { + expect(toolNoGroup._meta).not.toHaveProperty(GROUPS_META_KEY); + } + + // Verify prompts + const promptsResult = await client.listPrompts(); + const prompt1 = promptsResult.prompts.find(p => p.name === 'prompt1'); + const promptNoGroup = promptsResult.prompts.find(p => p.name === 'prompt-no-group'); + expect(prompt1?._meta?.[GROUPS_META_KEY]).toEqual(['mixed-group']); + if (promptNoGroup?._meta) { + expect(promptNoGroup._meta).not.toHaveProperty(GROUPS_META_KEY); + } + + // Verify resources + const resourcesResult = await client.listResources(); + const resource1 = resourcesResult.resources.find(r => r.name === 'resource1'); + const resourceNoGroup = resourcesResult.resources.find(r => r.name === 'resource-no-group'); + expect(resource1?._meta?.[GROUPS_META_KEY]).toEqual(['mixed-group']); + if (resourceNoGroup?._meta) { + expect(resourceNoGroup._meta).not.toHaveProperty(GROUPS_META_KEY); + } + }); + + test('should add a group to another group', async () => { + server.registerGroup('parent-group', { + description: 'A parent group' + }); + + server.registerGroup('child-group', { + description: 'A child group', + _meta: { + [GROUPS_META_KEY]: ['parent-group'] + } + }); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await client.listGroups(); + const childGroup = result.groups.find(g => g.name === 'child-group'); + expect(childGroup?._meta?.[GROUPS_META_KEY]).toEqual(['parent-group']); + }); +}); diff --git a/test/integration/test/client/client.test.ts b/test/integration/test/client/client.test.ts index 5574a2d84..8e49f0f6d 100644 --- a/test/integration/test/client/client.test.ts +++ b/test/integration/test/client/client.test.ts @@ -2,7 +2,7 @@ /* eslint-disable no-constant-binary-expression */ /* eslint-disable @typescript-eslint/no-unused-expressions */ import { Client, getSupportedElicitationModes } from '@modelcontextprotocol/client'; -import type { Prompt, Resource, Tool, Transport } from '@modelcontextprotocol/core'; +import type { Group, Prompt, Resource, Tool, Transport } from '@modelcontextprotocol/core'; import { CallToolRequestSchema, CallToolResultSchema, @@ -14,6 +14,7 @@ import { InitializeRequestSchema, InMemoryTransport, LATEST_PROTOCOL_VERSION, + ListGroupsResultSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListRootsRequestSchema, @@ -1292,6 +1293,120 @@ test('should handle tool list changed notification with auto refresh', async () expect(notifications[0]![1]?.[1]!.name).toBe('test-tool'); }); +/*** + * Test: Handle Group List Changed Notifications with Manual Refresh + */ +test('should handle group list changed notification with manual refresh', async () => { + // List changed notifications + const notifications: [Error | null, Group[] | null][] = []; + + const server = new McpServer({ + name: 'test-server', + version: '1.0.0' + }); + + // Configure listChanged handler in constructor + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + listChanged: { + groups: { + autoRefresh: false, + onChanged: (err, groups) => { + notifications.push([err, groups]); + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + // Register initial group - this sets up the capability and handlers + server.registerGroup('initial-group', { + description: 'Initial group' + }); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Register another group - this triggers listChanged notification + server.registerGroup('test-group', { + description: 'A test group' + }); + + // Wait for the debounced notifications to be processed + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Should be 1 notification with null groups because autoRefresh is false + expect(notifications).toHaveLength(1); + expect(notifications[0]![0]).toBeNull(); + expect(notifications[0]![1]).toBeNull(); + + // Now refresh groups + const result = await client.listGroups(); + expect(result.groups).toHaveLength(2); + expect(result.groups.find(g => g.name === 'test-group')).toBeDefined(); +}); + +/*** + * Test: Handle Group List Changed Notifications with Auto Refresh + */ +test('should handle group list changed notification with auto refresh', async () => { + // List changed notifications + const notifications: [Error | null, Group[] | null][] = []; + + const server = new McpServer({ + name: 'test-server', + version: '1.0.0' + }); + + // Register initial group + server.registerGroup('initial-group', { + description: 'Initial group' + }); + + // Configure listChanged handler in constructor + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + listChanged: { + groups: { + onChanged: (err, groups) => { + notifications.push([err, groups]); + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result1 = await client.listGroups(); + expect(result1.groups).toHaveLength(1); + + // Register another group - this triggers listChanged notification + server.registerGroup('test-group', { + description: 'A test group' + }); + + // Wait for the debounced notifications to be processed + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Should be 1 notification with 2 groups because autoRefresh is true + expect(notifications).toHaveLength(1); + expect(notifications[0]![0]).toBeNull(); + expect(notifications[0]![1]).toHaveLength(2); + expect(notifications[0]![1]?.[1]!.name).toBe('test-group'); +}); + /*** * Test: Handle Tool List Changed Notifications with Manual Refresh */ From d54e5d245a60767105ba8b4acbebb3fe0480e7c2 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 17 Jan 2026 14:16:08 -0500 Subject: [PATCH 02/11] Add example client and server for groups. * In groupsExample.ts - implement a server with - two parent groups (communications and work) - several child groups (email, calendar, spreadsheets, documents, todos). - prompts, resources, and tools organized into groups * In groupsExampleClient.ts - implement a command line REPL with commands - all/a - list all groups, tools, resources, and prompts - groups/g/enter - list available groups - help/h - for a list of commands - quit/q - to exit the program - enter a command name or a list of groups to filter by * In examples/client/README.md - document the example client groupsExampleClient.ts * In examples/server/README.md - document the example server groupsExample.ts --- examples/client/README.md | 9 + examples/client/src/groupsExampleClient.ts | 313 ++++++++++++++ examples/server/README.md | 43 +- examples/server/src/groupsExample.ts | 470 +++++++++++++++++++++ 4 files changed, 821 insertions(+), 14 deletions(-) create mode 100644 examples/client/src/groupsExampleClient.ts create mode 100644 examples/server/src/groupsExample.ts diff --git a/examples/client/README.md b/examples/client/README.md index 12a2b0d68..f8ec1bf63 100644 --- a/examples/client/README.md +++ b/examples/client/README.md @@ -36,6 +36,7 @@ Most clients expect a server to be running. Start one from [`../server/README.md | Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) | | URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | | Task interactive client | Demonstrates task-based execution + interactive server→client requests. | [`src/simpleTaskInteractiveClient.ts`](src/simpleTaskInteractiveClient.ts) | +| Primitive groups filtering client | CLI client that fetches groups/tools/resources/prompts and filters locally by group. | [`src/groupsExampleClient.ts`](src/groupsExampleClient.ts) | ## URL elicitation example (server + client) @@ -50,3 +51,11 @@ Then run the client: ```bash pnpm --filter @modelcontextprotocol/examples-client exec tsx src/elicitationUrlExample.ts ``` + +## Primitive groups example (server + client) + +Run the client (it spawns the matching stdio server by default): + +```bash +pnpm --filter @modelcontextprotocol/examples-client exec tsx src/groupsExampleClient.ts +``` diff --git a/examples/client/src/groupsExampleClient.ts b/examples/client/src/groupsExampleClient.ts new file mode 100644 index 000000000..7aa5d3edb --- /dev/null +++ b/examples/client/src/groupsExampleClient.ts @@ -0,0 +1,313 @@ +// Run with: +// pnpm --filter @modelcontextprotocol/examples-client exec tsx src/groupsExampleClient.ts +// +// This example spawns the matching stdio server by default. To point at a different stdio server: +// pnpm --filter @modelcontextprotocol/examples-client exec tsx src/groupsExampleClient.ts --server-command --server-args "..." + +import path from 'node:path'; +import process from 'node:process'; +import { createInterface } from 'node:readline'; +import { fileURLToPath } from 'node:url'; + +import type { Group } from '@modelcontextprotocol/client'; +import { Client, GROUPS_META_KEY, StdioClientTransport } from '@modelcontextprotocol/client'; + +type GroupName = string; + +function parseGroupList(input: string): string[] { + return input + .split(/[\s,]+/) + .map(s => s.trim()) + .filter(Boolean); +} + +function groupMembership(meta: unknown): string[] { + if (!meta || typeof meta !== 'object') { + return []; + } + + const record = meta as Record; + const value = record[GROUPS_META_KEY]; + if (!Array.isArray(value)) { + return []; + } + + return value.filter((v): v is string => typeof v === 'string'); +} + +function buildParentToChildrenMap(groups: Group[]): Map> { + const map = new Map>(); + + for (const group of groups) { + const parents = groupMembership(group._meta); + for (const parent of parents) { + if (!map.has(parent)) { + map.set(parent, new Set()); + } + map.get(parent)!.add(group.name); + } + } + + return map; +} + +function expandWithDescendants(selected: string[], parentToChildren: Map>): Set { + const out = new Set(); + const queue: GroupName[] = []; + + for (const g of selected) { + if (!out.has(g)) { + out.add(g); + queue.push(g); + } + } + + while (queue.length > 0) { + const current = queue.shift()!; + const children = parentToChildren.get(current); + if (!children) { + continue; + } + for (const child of children) { + if (!out.has(child)) { + out.add(child); + queue.push(child); + } + } + } + + return out; +} + +function expandDescendantsExcludingSelf(selected: string[], parentToChildren: Map>): Set { + const out = new Set(); + const queue: GroupName[] = []; + + for (const g of selected) { + const children = parentToChildren.get(g); + // Only list groups that are *contained by* the user-entered group(s). + // If a user enters a leaf group, it contains nothing, so it should not appear in the output. + if (!children || children.size === 0) { + continue; + } + + for (const child of children) { + if (!out.has(child)) { + out.add(child); + queue.push(child); + } + } + } + + while (queue.length > 0) { + const current = queue.shift()!; + const children = parentToChildren.get(current); + if (!children) { + continue; + } + for (const child of children) { + if (!out.has(child)) { + out.add(child); + queue.push(child); + } + } + } + + return out; +} + +function formatBulletList(items: Array<{ name: string; description?: string }>): string { + if (items.length === 0) { + return '(none)'; + } + + return items + .map(i => { + const desc = i.description ? ` — ${i.description}` : ''; + return `- ${i.name}${desc}`; + }) + .join('\n'); +} + +function printHelp() { + console.log('\nCommands:'); + console.log(' all (a) List all groups, tools, resources, and prompts'); + console.log(' groups (g/enter) List available groups '); + console.log(' help (h) Show this help'); + console.log(' exit (e) Quit'); + console.log(' Filter by one or more groups (comma or space-separated)'); +} + +function parseArgs(argv: string[]) { + const parsed: { serverCommand?: string; serverArgs?: string[] } = {}; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--server-command' && argv[i + 1]) { + parsed.serverCommand = argv[i + 1]!; + i++; + continue; + } + if (arg === '--server-args' && argv[i + 1]) { + // A single string that will be split on whitespace. Intended for simple use. + parsed.serverArgs = argv[i + 1]!.split(/\s+/).filter(Boolean); + i++; + continue; + } + } + + return parsed; +} + +async function run(): Promise { + const argv = process.argv.slice(2); + const options = parseArgs(argv); + + const thisFile = fileURLToPath(import.meta.url); + const clientSrcDir = path.dirname(thisFile); + const clientPkgDir = path.resolve(clientSrcDir, '..'); + const defaultServerScript = path.resolve(clientPkgDir, '..', 'server', 'src', 'groupsExample.ts'); + + const serverCommand = options.serverCommand ?? 'pnpm'; + const serverArgs = options.serverArgs ?? ['tsx', defaultServerScript]; + + console.log('======================='); + console.log('Groups filtering client'); + console.log('======================='); + console.log(`Starting stdio server: ${serverCommand} ${serverArgs.join(' ')}`); + + const transport = new StdioClientTransport({ + command: serverCommand, + args: serverArgs, + cwd: clientPkgDir, + stderr: 'inherit' + }); + + const client = new Client({ name: 'groups-example-client', version: '1.0.0' }); + await client.connect(transport); + + const [groupsResult, toolsResult, resourcesResult, promptsResult] = await Promise.all([ + client.listGroups(), + client.listTools(), + client.listResources(), + client.listPrompts() + ]); + + const groups = groupsResult.groups; + const tools = toolsResult.tools; + const resources = resourcesResult.resources; + const prompts = promptsResult.prompts; + + const groupNames = new Set(groups.map(g => g.name)); + const parentToChildren = buildParentToChildrenMap(groups); + + console.log(`\nFetched: ${groups.length} groups, ${tools.length} tools, ${resources.length} resources, ${prompts.length} prompts.`); + console.log(`Available groups: ${[...groupNames].sort().join(', ')}`); + + const rl = createInterface({ input: process.stdin, output: process.stdout }); + + const question = (prompt: string) => + new Promise(resolve => { + rl.question(prompt, answer => resolve(answer.trim())); + }); + + printHelp(); + + while (true) { + let input = await question('\nEnter groups to filter by (or "all", "help", "quit"): '); + if (!input) { + input = 'groups'; + } + + const lower = input.toLowerCase(); + + // Handle all command + if (lower === 'all' || lower === 'a') { + const sortedGroups = [...groups].sort((a, b) => a.name.localeCompare(b.name)); + const sortedTools = [...tools].sort((a, b) => a.name.localeCompare(b.name)); + const sortedResources = [...resources].sort((a, b) => a.name.localeCompare(b.name)); + const sortedPrompts = [...prompts].sort((a, b) => a.name.localeCompare(b.name)); + + console.log('\nGroups:'); + console.log(formatBulletList(sortedGroups.map(g => ({ name: g.name, description: g.description })))); + + console.log('\nTools:'); + console.log(formatBulletList(sortedTools.map(t => ({ name: t.name, description: t.description })))); + + console.log('\nResources:'); + console.log(formatBulletList(sortedResources.map(r => ({ name: r.name, description: r.description })))); + + console.log('\nPrompts:'); + console.log(formatBulletList(sortedPrompts.map(p => ({ name: p.name, description: p.description })))); + continue; + } + + // Handle list command + if (lower === 'groups' || lower === 'g') { + const sortedGroups = [...groups].sort((a, b) => a.name.localeCompare(b.name)); + console.log('\nGroups:'); + console.log(formatBulletList(sortedGroups.map(g => ({ name: g.name, description: g.description })))); + continue; + } + + // Handle help command + if (lower === 'help' || lower === 'h' || lower === '?') { + printHelp(); + continue; + } + + // Handle quit command + if (lower === 'quit' || lower === 'q') { + rl.close(); + await client.close(); + process.exit(); + } + + // Handle a group list + const requested = parseGroupList(input); + const unknown = requested.filter(g => !groupNames.has(g)); + if (unknown.length > 0) { + console.log(`Unknown group(s): ${unknown.join(', ')}`); + } + + const validRequested = requested.filter(g => groupNames.has(g)); + if (validRequested.length === 0) { + console.log('No valid groups provided. Type "list" to see available groups.'); + continue; + } + + const selected = expandWithDescendants(validRequested, parentToChildren); + const groupsToList = expandDescendantsExcludingSelf(validRequested, parentToChildren); + + const selectedGroups = groups.filter(g => groupsToList.has(g.name)).sort((a, b) => a.name.localeCompare(b.name)); + + const selectedTools = tools + .filter(t => groupMembership(t._meta).some(g => selected.has(g))) + .sort((a, b) => a.name.localeCompare(b.name)); + + const selectedResources = resources + .filter(r => groupMembership(r._meta).some(g => selected.has(g))) + .sort((a, b) => a.name.localeCompare(b.name)); + + const selectedPrompts = prompts + .filter(p => groupMembership(p._meta).some(g => selected.has(g))) + .sort((a, b) => a.name.localeCompare(b.name)); + + console.log('\nGroups:'); + console.log(formatBulletList(selectedGroups.map(g => ({ name: g.name, description: g.description })))); + + console.log('\nTools:'); + console.log(formatBulletList(selectedTools.map(t => ({ name: t.name, description: t.description })))); + + console.log('\nResources:'); + console.log(formatBulletList(selectedResources.map(r => ({ name: r.name, description: r.description })))); + + console.log('\nPrompts:'); + console.log(formatBulletList(selectedPrompts.map(p => ({ name: p.name, description: p.description })))); + } +} + +run().catch(error => { + console.error('Client error:', error); + process.exit(1); +}); diff --git a/examples/server/README.md b/examples/server/README.md index bfa67fd53..644af20d1 100644 --- a/examples/server/README.md +++ b/examples/server/README.md @@ -25,20 +25,21 @@ pnpm tsx src/simpleStreamableHttp.ts ## Example index -| Scenario | Description | File | -| --------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -| Streamable HTTP server (stateful) | Feature-rich server with tools/resources/prompts, logging, tasks, sampling, and optional OAuth. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) | -| Streamable HTTP server (stateless) | No session tracking; good for simple API-style servers. | [`src/simpleStatelessStreamableHttp.ts`](src/simpleStatelessStreamableHttp.ts) | -| JSON response mode (no SSE) | Streamable HTTP with JSON-only responses and limited notifications. | [`src/jsonResponseStreamableHttp.ts`](src/jsonResponseStreamableHttp.ts) | -| Server notifications over Streamable HTTP | Demonstrates server-initiated notifications via GET+SSE. | [`src/standaloneSseWithGetStreamableHttp.ts`](src/standaloneSseWithGetStreamableHttp.ts) | -| Deprecated HTTP+SSE server (legacy) | Legacy HTTP+SSE transport for backwards-compatibility testing. | [`src/simpleSseServer.ts`](src/simpleSseServer.ts) | -| Backwards-compatible server (Streamable HTTP + SSE) | One server that supports both Streamable HTTP and legacy SSE clients. | [`src/sseAndStreamableHttpCompatibleServer.ts`](src/sseAndStreamableHttpCompatibleServer.ts) | -| Form elicitation server | Collects **non-sensitive** user input via schema-driven forms. | [`src/elicitationFormExample.ts`](src/elicitationFormExample.ts) | -| URL elicitation server | Secure browser-based flows for **sensitive** input (API keys, OAuth, payments). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | -| Sampling + tasks server | Demonstrates sampling and experimental task-based execution. | [`src/toolWithSampleServer.ts`](src/toolWithSampleServer.ts) | -| Task interactive server | Task-based execution with interactive server→client requests. | [`src/simpleTaskInteractive.ts`](src/simpleTaskInteractive.ts) | -| Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) | -| SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) | +| Scenario | Description | File | +| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| Streamable HTTP server (stateful) | Feature-rich server with tools/resources/prompts, logging, tasks, sampling, and optional OAuth. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) | +| Streamable HTTP server (stateless) | No session tracking; good for simple API-style servers. | [`src/simpleStatelessStreamableHttp.ts`](src/simpleStatelessStreamableHttp.ts) | +| JSON response mode (no SSE) | Streamable HTTP with JSON-only responses and limited notifications. | [`src/jsonResponseStreamableHttp.ts`](src/jsonResponseStreamableHttp.ts) | +| Server notifications over Streamable HTTP | Demonstrates server-initiated notifications via GET+SSE. | [`src/standaloneSseWithGetStreamableHttp.ts`](src/standaloneSseWithGetStreamableHttp.ts) | +| Deprecated HTTP+SSE server (legacy) | Legacy HTTP+SSE transport for backwards-compatibility testing. | [`src/simpleSseServer.ts`](src/simpleSseServer.ts) | +| Backwards-compatible server (Streamable HTTP + SSE) | One server that supports both Streamable HTTP and legacy SSE clients. | [`src/sseAndStreamableHttpCompatibleServer.ts`](src/sseAndStreamableHttpCompatibleServer.ts) | +| Form elicitation server | Collects **non-sensitive** user input via schema-driven forms. | [`src/elicitationFormExample.ts`](src/elicitationFormExample.ts) | +| URL elicitation server | Secure browser-based flows for **sensitive** input (API keys, OAuth, payments). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | +| Sampling + tasks server | Demonstrates sampling and experimental task-based execution. | [`src/toolWithSampleServer.ts`](src/toolWithSampleServer.ts) | +| Primitive groups server | Demonstrates registering primitive groups and assigning groups, tools, resources, and prompts to groups. | [`src/groupsExample.ts`](src/groupsExample.ts) | +| Task interactive server | Task-based execution with interactive server→client requests. | [`src/simpleTaskInteractive.ts`](src/simpleTaskInteractive.ts) | +| Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) | +| SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) | ## OAuth demo flags (Streamable HTTP server) @@ -61,6 +62,20 @@ Run the client in another terminal: pnpm --filter @modelcontextprotocol/examples-client exec tsx src/elicitationUrlExample.ts ``` +## Primitive groups example (server + client) + +Run the server (stdio): + +```bash +pnpm --filter @modelcontextprotocol/examples-server exec tsx src/groupsExample.ts +``` + +Then run the client (it spawns the server by default): + +```bash +pnpm --filter @modelcontextprotocol/examples-client exec tsx src/groupsExampleClient.ts +``` + ## Multi-node deployment patterns When deploying MCP servers in a horizontally scaled environment (multiple server instances), there are a few different options that can be useful for different use cases: diff --git a/examples/server/src/groupsExample.ts b/examples/server/src/groupsExample.ts new file mode 100644 index 000000000..76a255ef8 --- /dev/null +++ b/examples/server/src/groupsExample.ts @@ -0,0 +1,470 @@ +// Run with: +// pnpm --filter @modelcontextprotocol/examples-server exec tsx src/groupsExample.ts + +import type { CallToolResult, GetPromptResult, ReadResourceResult } from '@modelcontextprotocol/server'; +import { GROUPS_META_KEY, McpServer, StdioServerTransport } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const server = new McpServer({ + name: 'groups-example-server', + version: '1.0.0' +}); + +function metaForGroup(groupName: string) { + return { + _meta: { + [GROUPS_META_KEY]: [groupName] + } + }; +} + +// Parent groups +server.registerGroup('work', { + title: 'Work', + description: 'Tools, resources, and prompts related to day-to-day work.' +}); + +server.registerGroup('communications', { + title: 'Communications', + description: 'Tools, resources, and prompts related to messaging and scheduling.' +}); + +// Child groups +server.registerGroup('spreadsheets', { + title: 'Spreadsheets', + description: 'Spreadsheet-like operations: create sheets, add rows, and do quick calculations.', + _meta: { + [GROUPS_META_KEY]: ['work'] + } +}); + +server.registerGroup('documents', { + title: 'Documents', + description: 'Document drafting, editing, and summarization workflows.', + _meta: { + [GROUPS_META_KEY]: ['work'] + } +}); + +server.registerGroup('todos', { + title: 'Todos', + description: 'Task capture and lightweight task management.', + _meta: { + [GROUPS_META_KEY]: ['work'] + } +}); + +server.registerGroup('email', { + title: 'Email', + description: 'Email composition and inbox-oriented operations.', + _meta: { + [GROUPS_META_KEY]: ['communications'] + } +}); + +server.registerGroup('calendar', { + title: 'Calendar', + description: 'Scheduling operations and event management.', + _meta: { + [GROUPS_META_KEY]: ['communications'] + } +}); + +// ===== Tools ===== + +// Email tools +server.registerTool( + 'email_send', + { + description: 'Send an email message.', + inputSchema: { + to: z.string().describe('Recipient email address'), + subject: z.string().describe('Email subject'), + body: z.string().describe('Email body') + }, + ...metaForGroup('email') + }, + async ({ to, subject }): Promise => { + return { content: [{ type: 'text', text: `Sent email to ${to} with subject "${subject}".` }] }; + } +); + +server.registerTool( + 'email_search_inbox', + { + description: 'Search the inbox by query string.', + inputSchema: { + query: z.string().describe('Search query') + }, + ...metaForGroup('email') + }, + async ({ query }): Promise => { + return { content: [{ type: 'text', text: `Searched inbox for "${query}".` }] }; + } +); + +// Calendar tools +server.registerTool( + 'calendar_create_event', + { + description: 'Create a calendar event.', + inputSchema: { + title: z.string().describe('Event title'), + when: z.string().describe('When the event occurs (free-form, e.g. "tomorrow 2pm")') + }, + ...metaForGroup('calendar') + }, + async ({ title, when }): Promise => { + return { content: [{ type: 'text', text: `Created calendar event "${title}" at ${when}.` }] }; + } +); + +server.registerTool( + 'calendar_list_upcoming', + { + description: 'List upcoming calendar events (demo).', + inputSchema: { + days: z.number().describe('Number of days ahead to look').default(7) + }, + ...metaForGroup('calendar') + }, + async ({ days }): Promise => { + return { content: [{ type: 'text', text: `Listed upcoming calendar events for the next ${days} day(s).` }] }; + } +); + +// Spreadsheets tools +server.registerTool( + 'spreadsheets_create', + { + description: 'Create a new spreadsheet.', + inputSchema: { + name: z.string().describe('Spreadsheet name') + }, + ...metaForGroup('spreadsheets') + }, + async ({ name }): Promise => { + return { content: [{ type: 'text', text: `Created spreadsheet "${name}".` }] }; + } +); + +server.registerTool( + 'spreadsheets_add_row', + { + description: 'Add a row to a spreadsheet.', + inputSchema: { + spreadsheet: z.string().describe('Spreadsheet name'), + values: z.array(z.string()).describe('Row values') + }, + ...metaForGroup('spreadsheets') + }, + async ({ spreadsheet, values }): Promise => { + return { + content: [{ type: 'text', text: `Added row to "${spreadsheet}": [${values.join(', ')}].` }] + }; + } +); + +// Documents tools +server.registerTool( + 'documents_create', + { + description: 'Create a document draft.', + inputSchema: { + title: z.string().describe('Document title') + }, + ...metaForGroup('documents') + }, + async ({ title }): Promise => { + return { content: [{ type: 'text', text: `Created document "${title}".` }] }; + } +); + +server.registerTool( + 'documents_summarize', + { + description: 'Summarize a document (demo).', + inputSchema: { + title: z.string().describe('Document title') + }, + ...metaForGroup('documents') + }, + async ({ title }): Promise => { + return { content: [{ type: 'text', text: `Summarized document "${title}".` }] }; + } +); + +// Todos tools +server.registerTool( + 'todos_add', + { + description: 'Add a todo item.', + inputSchema: { + text: z.string().describe('Todo text') + }, + ...metaForGroup('todos') + }, + async ({ text }): Promise => { + return { content: [{ type: 'text', text: `Added todo: "${text}".` }] }; + } +); + +server.registerTool( + 'todos_complete', + { + description: 'Mark a todo item complete.', + inputSchema: { + id: z.string().describe('Todo id') + }, + ...metaForGroup('todos') + }, + async ({ id }): Promise => { + return { content: [{ type: 'text', text: `Completed todo with id ${id}.` }] }; + } +); + +// ===== Resources ===== + +server.registerResource( + 'calendar_overview', + 'groups://calendar/overview', + { + mimeType: 'text/plain', + description: 'A short overview of calendar-related concepts and workflows.', + ...metaForGroup('calendar') + }, + async (): Promise => { + return { + contents: [ + { + uri: 'groups://calendar/overview', + text: + 'Calendars help coordinate time. Common workflows include creating events, inviting attendees, setting reminders, and reviewing upcoming commitments.\n\n' + + 'Good scheduling habits include adding agendas, assigning owners, and keeping event titles descriptive so they are searchable.' + } + ] + }; + } +); + +server.registerResource( + 'email_overview', + 'groups://email/overview', + { + mimeType: 'text/plain', + description: 'A short overview of email etiquette and structure.', + ...metaForGroup('email') + }, + async (): Promise => { + return { + contents: [ + { + uri: 'groups://email/overview', + text: + 'Email is best for asynchronous communication with a clear subject, concise context, and a specific call to action.\n\n' + + 'Strong emails include a brief greeting, the purpose in the first paragraph, and any needed links or bullet points.' + } + ] + }; + } +); + +server.registerResource( + 'spreadsheets_overview', + 'groups://spreadsheets/overview', + { + mimeType: 'text/plain', + description: 'A short overview of spreadsheet structure and best practices.', + ...metaForGroup('spreadsheets') + }, + async (): Promise => { + return { + contents: [ + { + uri: 'groups://spreadsheets/overview', + text: + 'Spreadsheets organize data into rows and columns. Use consistent headers, keep one concept per column, and avoid mixing units in a single column.\n\n' + + 'For collaboration, document assumptions and prefer formulas over manual calculations.' + } + ] + }; + } +); + +server.registerResource( + 'documents_overview', + 'groups://documents/overview', + { + mimeType: 'text/plain', + description: 'A short overview of document workflows.', + ...metaForGroup('documents') + }, + async (): Promise => { + return { + contents: [ + { + uri: 'groups://documents/overview', + text: + 'Documents capture long-form thinking. Common workflows include drafting, reviewing, suggesting edits, and summarizing key decisions.\n\n' + + 'Keep sections scannable with headings, and ensure decisions and next steps are easy to find.' + } + ] + }; + } +); + +server.registerResource( + 'todos_overview', + 'groups://todos/overview', + { + mimeType: 'text/plain', + description: 'A short overview of task management basics.', + ...metaForGroup('todos') + }, + async (): Promise => { + return { + contents: [ + { + uri: 'groups://todos/overview', + text: + 'Todo lists help track commitments. Capture tasks as verbs, keep them small, and regularly review to prevent backlog buildup.\n\n' + + 'If a task takes multiple steps, split it into subtasks or link it to a more detailed plan.' + } + ] + }; + } +); + +// ===== Prompts ===== + +server.registerPrompt( + 'email_thank_contributor', + { + description: 'Compose an email thanking someone for their recent contributions.', + argsSchema: { + name: z.string().describe('Recipient name') + }, + ...metaForGroup('email') + }, + async ({ name }): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Compose an email thanking ${name} for their recent contributions. Keep it warm, specific, and concise.` + } + } + ] + }; + } +); + +server.registerPrompt( + 'calendar_meeting_agenda', + { + description: 'Draft a short agenda for an upcoming meeting.', + argsSchema: { + topic: z.string().describe('Meeting topic') + }, + ...metaForGroup('calendar') + }, + async ({ topic }): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Draft a short meeting agenda for a meeting about: ${topic}. Include goals, timeboxes, and expected outcomes.` + } + } + ] + }; + } +); + +server.registerPrompt( + 'spreadsheets_quick_analysis', + { + description: 'Suggest a simple spreadsheet layout for tracking a metric.', + argsSchema: { + metric: z.string().describe('The metric to track') + }, + ...metaForGroup('spreadsheets') + }, + async ({ metric }): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Suggest a simple spreadsheet layout for tracking: ${metric}. Include column headers and a brief note on how to use it.` + } + } + ] + }; + } +); + +server.registerPrompt( + 'documents_write_outline', + { + description: 'Create an outline for a document on a topic.', + argsSchema: { + topic: z.string().describe('Document topic') + }, + ...metaForGroup('documents') + }, + async ({ topic }): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Create a clear outline for a document about: ${topic}. Use headings and a short description under each heading.` + } + } + ] + }; + } +); + +server.registerPrompt( + 'todos_plan_day', + { + description: 'Turn a list of tasks into a simple day plan.', + argsSchema: { + tasks: z.array(z.string()).describe('Tasks to plan') + }, + ...metaForGroup('todos') + }, + async ({ tasks }): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Create a simple plan for the day from these tasks:\n- ${tasks.join('\n- ')}\n\nPrioritize and group similar tasks.` + } + } + ] + }; + } +); + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + // IMPORTANT: In stdio mode, avoid writing to stdout (it is used for MCP messages). + console.error('Groups example MCP server running on stdio.'); +} + +main().catch(error => { + console.error('Server error:', error); + process.exit(1); +}); From 96ab61c9f8dd3c4881957eebcbe8f06776e617d7 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 17 Jan 2026 14:39:43 -0500 Subject: [PATCH 03/11] * In groupsExampleClient.ts - simplify command prompt --- examples/client/src/groupsExampleClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/client/src/groupsExampleClient.ts b/examples/client/src/groupsExampleClient.ts index 7aa5d3edb..c64e9fd76 100644 --- a/examples/client/src/groupsExampleClient.ts +++ b/examples/client/src/groupsExampleClient.ts @@ -214,7 +214,7 @@ async function run(): Promise { printHelp(); while (true) { - let input = await question('\nEnter groups to filter by (or "all", "help", "quit"): '); + let input = await question('\nEnter a command or a list of groups to filter by: '); if (!input) { input = 'groups'; } From bca51c700dfa37817105b3a281dbbe1553c67cfe Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 23 Jan 2026 14:34:36 -0500 Subject: [PATCH 04/11] * In types.ts - replace GroupSchema._meta definition with the simplified one used in the other primitives --- packages/core/src/types/types.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index 9e0ae99db..4296ddd90 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -459,15 +459,11 @@ export const GroupSchema = z.object({ /** * Optional _meta object that can contain protocol-reserved and custom fields. */ - _meta: z - .object({ - /** - * A list of group names containing this primitive. - */ - [GROUPS_META_KEY]: z.array(z.string()).optional() - }) - .catchall(z.unknown()) - .optional() + _meta: z.optional( + z.looseObject({ + [GROUPS_META_KEY]: z.array(z.string()).optional() + }) + ) }); const FormElicitationCapabilitySchema = z.intersection( From 245ef06bfa181773d68ba4fa13d0457bf8e61129 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 24 Jan 2026 16:05:34 -0500 Subject: [PATCH 05/11] * In types.ts - define GroupsWithMetaSchema - In all primitives, replace _meta defintion with reference to GroupsWithMetaSchema --- packages/core/src/types/types.ts | 59 +++++++++++++------------------- pnpm-lock.yaml | 6 ++++ 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index 4296ddd90..3439c5d3a 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -92,6 +92,16 @@ export const ProgressTokenSchema = z.union([z.string(), z.number().int()]); */ export const CursorSchema = z.string(); +/** + * Optional object that can have any properties, but if + * GROUPS_META_KEY is present, must be an array of strings + */ +const GroupMetaSchema = z.optional( + z.looseObject({ + [GROUPS_META_KEY]: z.array(z.string()).optional() + }) +); + /** * Task creation parameters, used to ask that the server create a task to represent a request. */ @@ -457,13 +467,9 @@ export const GroupSchema = z.object({ annotations: AnnotationsSchema.optional(), /** - * Optional _meta object that can contain protocol-reserved and custom fields. + * Metadata possibly including a group list */ - _meta: z.optional( - z.looseObject({ - [GROUPS_META_KEY]: z.array(z.string()).optional() - }) - ) + _meta: GroupMetaSchema }); const FormElicitationCapabilitySchema = z.intersection( @@ -815,11 +821,10 @@ export const TaskSchema = z.object({ */ statusMessage: z.optional(z.string()), - _meta: z.optional( - z.looseObject({ - [GROUPS_META_KEY]: z.array(z.string()).optional() - }) - ) + /** + * Metadata possibly including a group list + */ + _meta: GroupMetaSchema }); /** @@ -1008,14 +1013,9 @@ export const ResourceSchema = z.object({ annotations: AnnotationsSchema.optional(), /** - * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) - * for notes on _meta usage. + * Metadata possibly including a group list */ - _meta: z.optional( - z.looseObject({ - [GROUPS_META_KEY]: z.array(z.string()).optional() - }) - ) + _meta: GroupMetaSchema }); /** @@ -1047,10 +1047,9 @@ export const ResourceTemplateSchema = z.object({ annotations: AnnotationsSchema.optional(), /** - * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) - * for notes on _meta usage. + * Metadata possibly including a group list */ - _meta: z.optional(z.looseObject({})) + _meta: GroupMetaSchema }); /** @@ -1188,14 +1187,9 @@ export const PromptSchema = z.object({ */ arguments: z.optional(z.array(PromptArgumentSchema)), /** - * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) - * for notes on _meta usage. + * Metadata possibly including a group list */ - _meta: z.optional( - z.looseObject({ - [GROUPS_META_KEY]: z.array(z.string()).optional() - }) - ) + _meta: GroupMetaSchema }); /** @@ -1513,14 +1507,9 @@ export const ToolSchema = z.object({ execution: ToolExecutionSchema.optional(), /** - * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) - * for notes on _meta usage. + * Metadata possibly including a group list */ - _meta: z.optional( - z.looseObject({ - [GROUPS_META_KEY]: z.array(z.string()).optional() - }) - ) + _meta: GroupMetaSchema }); /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66600384f..9118b1f59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -594,6 +594,9 @@ importers: packages/middleware/express: dependencies: + cors: + specifier: ^2.8.5 + version: 2.8.5 express: specifier: catalog:runtimeServerOnly version: 5.2.1 @@ -613,6 +616,9 @@ importers: '@modelcontextprotocol/vitest-config': specifier: workspace:^ version: link:../../../common/vitest-config + '@types/cors': + specifier: ^2.8.19 + version: 2.8.19 '@types/express': specifier: catalog:devTools version: 5.0.6 From 60073041e2e0b72f9949e7a889d23a9f00e2c61d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 24 Jan 2026 16:14:02 -0500 Subject: [PATCH 06/11] Remove cors accidentally added to pnpm-lock.yaml --- pnpm-lock.yaml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26c287e4a..7820ada8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -609,9 +609,6 @@ importers: packages/middleware/express: dependencies: - cors: - specifier: ^2.8.5 - version: 2.8.5 express: specifier: catalog:runtimeServerOnly version: 5.2.1 @@ -631,9 +628,6 @@ importers: '@modelcontextprotocol/vitest-config': specifier: workspace:^ version: link:../../../common/vitest-config - '@types/cors': - specifier: ^2.8.19 - version: 2.8.19 '@types/express': specifier: catalog:devTools version: 5.0.6 From bb915709e501653173aeb6c64c142f581f062b5c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 24 Jan 2026 16:22:28 -0500 Subject: [PATCH 07/11] Remove deprecated/removed prompts accidentally kept during reconcile --- packages/server/src/server/mcp.ts | 67 ++++--------------------------- 1 file changed, 7 insertions(+), 60 deletions(-) diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 3de25026e..e8e17c1f7 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -862,17 +862,17 @@ export class McpServer { enable: () => registeredGroup.update({ enabled: true }), remove: () => registeredGroup.update({ name: null }), update: updates => { - if (typeof updates.name !== 'undefined' && updates.name !== name) { + if (updates.name !== undefined && updates.name !== name) { delete this._registeredGroups[name]; if (updates.name) this._registeredGroups[updates.name] = registeredGroup; name = updates.name ?? name; } - if (typeof updates.title !== 'undefined') registeredGroup.title = updates.title; - if (typeof updates.description !== 'undefined') registeredGroup.description = updates.description; - if (typeof updates.icons !== 'undefined') registeredGroup.icons = updates.icons; - if (typeof updates.annotations !== 'undefined') registeredGroup.annotations = updates.annotations; - if (typeof updates._meta !== 'undefined') registeredGroup._meta = updates._meta; - if (typeof updates.enabled !== 'undefined') registeredGroup.enabled = updates.enabled; + if (updates.title !== undefined) registeredGroup.title = updates.title; + if (updates.description !== undefined) registeredGroup.description = updates.description; + if (updates.icons !== undefined) registeredGroup.icons = updates.icons; + if (updates.annotations !== undefined) registeredGroup.annotations = updates.annotations; + if (updates._meta !== undefined) registeredGroup._meta = updates._meta; + if (updates.enabled !== undefined) registeredGroup.enabled = updates.enabled; if (updates.name === null) { delete this._registeredGroups[name]; @@ -985,59 +985,6 @@ export class McpServer { ); } - /** - * Registers a zero-argument prompt `name`, which will run the given function when the client calls it. - * @deprecated Use `registerPrompt` instead. - */ - prompt(name: string, cb: PromptCallback): RegisteredPrompt; - - /** - * Registers a zero-argument prompt `name` (with a description) which will run the given function when the client calls it. - * @deprecated Use `registerPrompt` instead. - */ - prompt(name: string, description: string, cb: PromptCallback): RegisteredPrompt; - - /** - * Registers a prompt `name` accepting the given arguments, which must be an object containing named properties associated with Zod schemas. When the client calls it, the function will be run with the parsed and validated arguments. - * @deprecated Use `registerPrompt` instead. - */ - prompt(name: string, argsSchema: Args, cb: PromptCallback): RegisteredPrompt; - - /** - * Registers a prompt `name` (with a description) accepting the given arguments, which must be an object containing named properties associated with Zod schemas. When the client calls it, the function will be run with the parsed and validated arguments. - * @deprecated Use `registerPrompt` instead. - */ - prompt( - name: string, - description: string, - argsSchema: Args, - cb: PromptCallback - ): RegisteredPrompt; - - prompt(name: string, ...rest: unknown[]): RegisteredPrompt { - if (this._registeredPrompts[name]) { - throw new Error(`Prompt ${name} is already registered`); - } - - let description: string | undefined; - if (typeof rest[0] === 'string') { - description = rest.shift() as string; - } - - let argsSchema: PromptArgsRawShape | undefined; - if (rest.length > 1) { - argsSchema = rest.shift() as PromptArgsRawShape; - } - - const cb = rest[0] as PromptCallback; - const registeredPrompt = this._createRegisteredPrompt(name, undefined, description, argsSchema, undefined, cb); - - this.setPromptRequestHandlers(); - this.sendPromptListChanged(); - - return registeredPrompt; - } - /** * Registers a prompt with a config object and callback. */ From d5d6219a6d1b7d4629b12373f99a8cdf2a0bb948 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 24 Jan 2026 16:45:07 -0500 Subject: [PATCH 08/11] In types.ts - Export GroupMeta In mcp.ts - Import GroupMeta - use as type of _meta argument in - _createRegisteredResource - automatically via ResourceMetadata which uses it - _createRegisteredResourceTemplate - automatically via ResourceMetadata which uses it - _createRegisteredPrompt - _createRegisteredGroup - _createRegisteredTool --- packages/core/src/types/types.ts | 3 ++- packages/server/src/server/mcp.ts | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index 5bb4d5226..58ffd138e 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -96,7 +96,7 @@ export const CursorSchema = z.string(); * Optional object that can have any properties, but if * GROUPS_META_KEY is present, must be an array of strings */ -const GroupMetaSchema = z.optional( +export const GroupMetaSchema = z.optional( z.looseObject({ [GROUPS_META_KEY]: z.array(z.string()).optional() }) @@ -2671,6 +2671,7 @@ export type RootsListChangedNotification = Infer; +export type GroupMeta = Infer; export type ListGroupsRequest = Infer; export type ListGroupsResult = Infer; export type GroupListChangedNotification = Infer; diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index e8e17c1f7..708f927a6 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -10,6 +10,7 @@ import type { CreateTaskResult, GetPromptResult, Group, + GroupMeta, Implementation, ListGroupsResult, ListPromptsResult, @@ -799,7 +800,7 @@ export class McpServer { title: string | undefined, description: string | undefined, argsSchema: PromptArgsRawShape | undefined, - _meta: Record | undefined, + _meta: GroupMeta, callback: PromptCallback ): RegisteredPrompt { const registeredPrompt: RegisteredPrompt = { @@ -849,7 +850,7 @@ export class McpServer { description: string | undefined, icons: Group['icons'] | undefined, annotations: Group['annotations'] | undefined, - _meta: Group['_meta'] | undefined + _meta: GroupMeta ): RegisteredGroup { const registeredGroup: RegisteredGroup = { title, @@ -901,7 +902,7 @@ export class McpServer { outputSchema: ZodRawShapeCompat | AnySchema | undefined, annotations: ToolAnnotations | undefined, execution: ToolExecution | undefined, - _meta: Record | undefined, + _meta: GroupMeta, handler: AnyToolHandler ): RegisteredTool { // Validate tool name according to SEP specification From d9dd38e274387559c8c96d2e640d8a581f3a41db Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 24 Jan 2026 16:53:10 -0500 Subject: [PATCH 09/11] In groupsExampleClient.ts and groupsExample.ts - fix linting complaints --- examples/client/src/groupsExampleClient.ts | 27 ++++++++++------------ examples/server/src/groupsExample.ts | 5 +--- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/examples/client/src/groupsExampleClient.ts b/examples/client/src/groupsExampleClient.ts index c64e9fd76..b61dd223b 100644 --- a/examples/client/src/groupsExampleClient.ts +++ b/examples/client/src/groupsExampleClient.ts @@ -202,7 +202,7 @@ async function run(): Promise { const parentToChildren = buildParentToChildrenMap(groups); console.log(`\nFetched: ${groups.length} groups, ${tools.length} tools, ${resources.length} resources, ${prompts.length} prompts.`); - console.log(`Available groups: ${[...groupNames].sort().join(', ')}`); + console.log(`Available groups: ${[...groupNames].toSorted().join(', ')}`); const rl = createInterface({ input: process.stdin, output: process.stdout }); @@ -223,10 +223,10 @@ async function run(): Promise { // Handle all command if (lower === 'all' || lower === 'a') { - const sortedGroups = [...groups].sort((a, b) => a.name.localeCompare(b.name)); - const sortedTools = [...tools].sort((a, b) => a.name.localeCompare(b.name)); - const sortedResources = [...resources].sort((a, b) => a.name.localeCompare(b.name)); - const sortedPrompts = [...prompts].sort((a, b) => a.name.localeCompare(b.name)); + const sortedGroups = [...groups].toSorted((a, b) => a.name.localeCompare(b.name)); + const sortedTools = [...tools].toSorted((a, b) => a.name.localeCompare(b.name)); + const sortedResources = [...resources].toSorted((a, b) => a.name.localeCompare(b.name)); + const sortedPrompts = [...prompts].toSorted((a, b) => a.name.localeCompare(b.name)); console.log('\nGroups:'); console.log(formatBulletList(sortedGroups.map(g => ({ name: g.name, description: g.description })))); @@ -244,7 +244,7 @@ async function run(): Promise { // Handle list command if (lower === 'groups' || lower === 'g') { - const sortedGroups = [...groups].sort((a, b) => a.name.localeCompare(b.name)); + const sortedGroups = [...groups].toSorted((a, b) => a.name.localeCompare(b.name)); console.log('\nGroups:'); console.log(formatBulletList(sortedGroups.map(g => ({ name: g.name, description: g.description })))); continue; @@ -260,7 +260,7 @@ async function run(): Promise { if (lower === 'quit' || lower === 'q') { rl.close(); await client.close(); - process.exit(); + throw new Error('User quit'); } // Handle a group list @@ -279,19 +279,19 @@ async function run(): Promise { const selected = expandWithDescendants(validRequested, parentToChildren); const groupsToList = expandDescendantsExcludingSelf(validRequested, parentToChildren); - const selectedGroups = groups.filter(g => groupsToList.has(g.name)).sort((a, b) => a.name.localeCompare(b.name)); + const selectedGroups = groups.filter(g => groupsToList.has(g.name)).toSorted((a, b) => a.name.localeCompare(b.name)); const selectedTools = tools .filter(t => groupMembership(t._meta).some(g => selected.has(g))) - .sort((a, b) => a.name.localeCompare(b.name)); + .toSorted((a, b) => a.name.localeCompare(b.name)); const selectedResources = resources .filter(r => groupMembership(r._meta).some(g => selected.has(g))) - .sort((a, b) => a.name.localeCompare(b.name)); + .toSorted((a, b) => a.name.localeCompare(b.name)); const selectedPrompts = prompts .filter(p => groupMembership(p._meta).some(g => selected.has(g))) - .sort((a, b) => a.name.localeCompare(b.name)); + .toSorted((a, b) => a.name.localeCompare(b.name)); console.log('\nGroups:'); console.log(formatBulletList(selectedGroups.map(g => ({ name: g.name, description: g.description })))); @@ -307,7 +307,4 @@ async function run(): Promise { } } -run().catch(error => { - console.error('Client error:', error); - process.exit(1); -}); +await run(); diff --git a/examples/server/src/groupsExample.ts b/examples/server/src/groupsExample.ts index 76a255ef8..f8bc8d931 100644 --- a/examples/server/src/groupsExample.ts +++ b/examples/server/src/groupsExample.ts @@ -464,7 +464,4 @@ async function main() { console.error('Groups example MCP server running on stdio.'); } -main().catch(error => { - console.error('Server error:', error); - process.exit(1); -}); +await main(); From 858d4090c90d3a2bfa9f84736085a1407df315d7 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 24 Jan 2026 17:49:44 -0500 Subject: [PATCH 10/11] In groupsExample.ts and groupsExampleClient.ts - Add documentation to examples - Add support for setting display depth --- examples/client/src/groupsExampleClient.ts | 665 ++++++++++++++------- examples/server/src/groupsExample.ts | 20 +- 2 files changed, 450 insertions(+), 235 deletions(-) diff --git a/examples/client/src/groupsExampleClient.ts b/examples/client/src/groupsExampleClient.ts index b61dd223b..82c6d3598 100644 --- a/examples/client/src/groupsExampleClient.ts +++ b/examples/client/src/groupsExampleClient.ts @@ -6,305 +6,506 @@ import path from 'node:path'; import process from 'node:process'; -import { createInterface } from 'node:readline'; -import { fileURLToPath } from 'node:url'; +import {createInterface} from 'node:readline'; +import {fileURLToPath} from 'node:url'; -import type { Group } from '@modelcontextprotocol/client'; -import { Client, GROUPS_META_KEY, StdioClientTransport } from '@modelcontextprotocol/client'; +import type {Group} from '@modelcontextprotocol/client'; +import {Client, GROUPS_META_KEY, StdioClientTransport} from '@modelcontextprotocol/client'; type GroupName = string; +/** + * Parse a user-entered group list. + * + * Accepts either comma-separated or whitespace-separated input (or a mix of both), e.g.: + * - `communications, work` + * - `communications work` + */ function parseGroupList(input: string): string[] { - return input - .split(/[\s,]+/) - .map(s => s.trim()) - .filter(Boolean); + return input + .split(/[\s,]+/) + .map(s => s.trim()) + .filter(Boolean); } +/** + * Extracts group membership from a primitive's `_meta` object. + * + * The MCP groups proposal uses `_meta[GROUPS_META_KEY]` to store a list of group names. + * - If `_meta` is missing or malformed, this returns `[]`. + * - Non-string entries are ignored. + */ function groupMembership(meta: unknown): string[] { - if (!meta || typeof meta !== 'object') { - return []; - } - - const record = meta as Record; - const value = record[GROUPS_META_KEY]; - if (!Array.isArray(value)) { - return []; - } - - return value.filter((v): v is string => typeof v === 'string'); + // `_meta` is defined as an open-ended metadata object on primitives, but it may be: + // - missing entirely + // - `null` + // - some other non-object value + // In all of those cases we treat it as “no group membership information available” + if (!meta || typeof meta !== 'object') { + return []; + } + + // We only need dictionary-style access (`record[key]`), so we cast to a generic record. + // This is intentionally tolerant: the server may include other meta keys we don't know about. + const record = meta as Record; + + // The groups proposal stores membership at `_meta[GROUPS_META_KEY]`. + // Convention: + // - For tools/resources/prompts: list of groups that primitive belongs to. + // - For groups themselves: list of parent groups that *contain* this group. + const value = record[GROUPS_META_KEY]; + if (!Array.isArray(value)) { + return []; + } + + // Be defensive: only keep string entries (ignore malformed values like numbers/objects). + return value.filter((v): v is string => typeof v === 'string'); } +/** + * Builds a directed adjacency map from parent group -> child groups. + * + * In this proposal, *child* groups declare their parent group(s) via `_meta[GROUPS_META_KEY]`. + * So we invert that relationship into a `parentToChildren` map to make traversal easier. + */ function buildParentToChildrenMap(groups: Group[]): Map> { - const map = new Map>(); - - for (const group of groups) { - const parents = groupMembership(group._meta); - for (const parent of parents) { - if (!map.has(parent)) { - map.set(parent, new Set()); - } - map.get(parent)!.add(group.name); - } + const map = new Map>(); + + for (const group of groups) { + // Each *child* group declares its parent group(s) via `_meta[GROUPS_META_KEY]`. + // Example: if group `email` has `_meta[GROUPS_META_KEY]=['communications']`, then + // `communications` *contains* `email`. + const parents = groupMembership(group._meta); + for (const parent of parents) { + // Build an adjacency list (parent -> children) so we can traverse “down” the graph. + // We store children in a Set to: + // - naturally dedupe if the server repeats membership + // - avoid multiple queue entries later during traversal + if (!map.has(parent)) { + map.set(parent, new Set()); + } + map.get(parent)!.add(group.name); } + } - return map; + return map; } -function expandWithDescendants(selected: string[], parentToChildren: Map>): Set { - const out = new Set(); - const queue: GroupName[] = []; +/** + * Returns every group name the client should consider during traversal. + * + * Some parent nodes may exist only as names referenced by children (i.e., appear in `_meta`) + * even if the server doesn't explicitly return them as `Group` objects. + */ +function allKnownGroupNames(groups: Group[], parentToChildren: Map>): Set { + const names = new Set(); + + for (const g of groups) { + names.add(g.name); + } + for (const parent of parentToChildren.keys()) { + names.add(parent); + } + + return names; +} - for (const g of selected) { - if (!out.has(g)) { - out.add(g); - queue.push(g); - } +/** + * Maximum descendant depth in *edges* found in the group graph. + * + * Example: + * - A leaf group has depth 0. + * - A parent with direct children has depth 1. + * + * Cycles are handled by refusing to evaluate a group already on the current path. + */ +function computeMaxDepthEdges(allGroups: Iterable, parentToChildren: Map>): number { + // We want a *global* maximum nesting depth to validate the user's `depth` setting. + // This is a graph problem (not necessarily a tree): a child can have multiple parents. + // + // We compute “depth in edges” rather than “depth in nodes”: + // - leaf = 0 + // - parent -> child = 1 + // This aligns cleanly with traversal where each step consumes one edge. + const memo = new Map(); + + const dfs = (node: GroupName, path: Set): number => { + // Memoization: once we've computed the best descendant depth for a node, + // we can reuse it across different starting points. + const cached = memo.get(node); + if (cached !== undefined) { + return cached; } - while (queue.length > 0) { - const current = queue.shift()!; - const children = parentToChildren.get(current); - if (!children) { - continue; - } - for (const child of children) { - if (!out.has(child)) { - out.add(child); - queue.push(child); - } + // Cycle safety: group graphs *should* be acyclic, but we must not assume that. + // If we re-enter a node already on the current recursion path, treat it as a leaf + // for the purpose of depth calculation and stop descending. + if (path.has(node)) { + return 0; + } + + // Track the active DFS stack so we can detect cycles specific to this path. + path.add(node); + const children = parentToChildren.get(node); + let best = 0; + if (children) { + for (const child of children) { + // If a direct child is already on the active path, we'd form a cycle. + // Skip it; other children may still extend depth. + if (path.has(child)) { + continue; } + + // “1 + dfs(child)” accounts for the edge from `node` to `child`. + best = Math.max(best, 1 + dfs(child, path)); + } } - return out; + // Pop from recursion stack before returning to the caller. + path.delete(node); + + // Cache the computed best depth for this node. + memo.set(node, best); + return best; + }; + + // Some parent groups might only exist as names referenced in `_meta` and not as full `Group` objects. + // That's why the caller passes `allGroups` rather than just `groups.map(g => g.name)`. + let max = 0; + for (const g of allGroups) { + max = Math.max(max, dfs(g, new Set())); + } + return max; } -function expandDescendantsExcludingSelf(selected: string[], parentToChildren: Map>): Set { - const out = new Set(); - const queue: GroupName[] = []; +/** + * Expands selected groups through the group graph up to a maximum number of edges. + * + * This function is intentionally: + * - **depth-limited**: `depthEdges` controls how far to traverse (in edges) + * - **cycle-safe**: a `visited` set prevents re-processing the same group and avoids loops + * + * `includeSelf` controls whether the returned set contains the starting groups. + * For this CLI's output, we typically exclude the requested groups from the displayed + * “Groups” section (a group doesn't “contain itself”). + */ +function expandWithinDepth( + selected: string[], + parentToChildren: Map>, + depthEdges: number, + includeSelf: boolean +): Set { + // `out` accumulates the groups we will return. + const out = new Set(); + + // `visited` ensures we evaluate any group at most once. This makes traversal: + // - cycle-safe (won't loop forever) + // - efficient (won't expand the same subgraph repeatedly) + const visited = new Set(); + + // We do a breadth-first traversal so “remaining depth” is easy to manage. + // Each queue item carries how many edges are still allowed from that node. + const queue: Array<{ name: GroupName; remaining: number }> = []; + + for (const g of selected) { + // Optionally include the selected group(s) themselves. + // For the CLI's “Groups” section we usually exclude these so a group doesn't “contain itself”. + if (includeSelf) { + out.add(g); + } + + // Seed traversal from each selected group, but only once per unique group. + if (!visited.has(g)) { + visited.add(g); + queue.push({name: g, remaining: depthEdges}); + } + } - for (const g of selected) { - const children = parentToChildren.get(g); - // Only list groups that are *contained by* the user-entered group(s). - // If a user enters a leaf group, it contains nothing, so it should not appear in the output. - if (!children || children.size === 0) { - continue; - } + while (queue.length > 0) { + // Take the next node to expand. + const {name: current, remaining} = queue.shift()!; - for (const child of children) { - if (!out.has(child)) { - out.add(child); - queue.push(child); - } - } + // No remaining budget means we stop expanding children from this node. + if (remaining <= 0) { + continue; } - while (queue.length > 0) { - const current = queue.shift()!; - const children = parentToChildren.get(current); - if (!children) { - continue; - } - for (const child of children) { - if (!out.has(child)) { - out.add(child); - queue.push(child); - } - } + const children = parentToChildren.get(current); + + // Missing entry means the node is a leaf (or unknown to our graph). Nothing to expand. + if (!children) { + continue; + } + for (const child of children) { + // A contained group is always included in the output set. + out.add(child); + + // Only enqueue the child if we haven't expanded it already. + // Note: we still add `child` to `out` even if visited, because it may be a child + // of multiple parents and should appear as “contained” regardless. + if (!visited.has(child)) { + visited.add(child); + queue.push({name: child, remaining: remaining - 1}); + } + } + } + + if (!includeSelf) { + // A second safety-net: even if traversal re-adds a selected group through an unusual + // cyclic or multi-parent configuration, ensure we don't list requested groups as “contained”. + for (const g of selected) { + out.delete(g); } + } - return out; + return out; } function formatBulletList(items: Array<{ name: string; description?: string }>): string { - if (items.length === 0) { - return '(none)'; - } - - return items - .map(i => { - const desc = i.description ? ` — ${i.description}` : ''; - return `- ${i.name}${desc}`; - }) - .join('\n'); + if (items.length === 0) { + return '(none)'; + } + + return items + .map(i => { + const desc = i.description ? ` — ${i.description}` : ''; + return `- ${i.name}${desc}`; + }) + .join('\n'); } function printHelp() { - console.log('\nCommands:'); - console.log(' all (a) List all groups, tools, resources, and prompts'); - console.log(' groups (g/enter) List available groups '); - console.log(' help (h) Show this help'); - console.log(' exit (e) Quit'); - console.log(' Filter by one or more groups (comma or space-separated)'); + console.log('\nCommands:'); + console.log(' all (a) List all groups, tools, resources, and prompts'); + console.log(' depth (d) [n] Show or set group display depth (1..max)'); + console.log(' groups (g/enter) List available groups '); + console.log(' help (h) Show this help'); + console.log(' exit (e/quit/q) Quit'); + console.log(' Filter by one or more groups (comma or space-separated)'); } function parseArgs(argv: string[]) { - const parsed: { serverCommand?: string; serverArgs?: string[] } = {}; - - for (let i = 0; i < argv.length; i++) { - const arg = argv[i]; - if (arg === '--server-command' && argv[i + 1]) { - parsed.serverCommand = argv[i + 1]!; - i++; - continue; - } - if (arg === '--server-args' && argv[i + 1]) { - // A single string that will be split on whitespace. Intended for simple use. - parsed.serverArgs = argv[i + 1]!.split(/\s+/).filter(Boolean); - i++; - continue; - } + const parsed: { serverCommand?: string; serverArgs?: string[] } = {}; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--server-command' && argv[i + 1]) { + parsed.serverCommand = argv[i + 1]!; + i++; + continue; + } + if (arg === '--server-args' && argv[i + 1]) { + // A single string that will be split on whitespace. Intended for simple use. + parsed.serverArgs = argv[i + 1]!.split(/\s+/).filter(Boolean); + i++; + continue; } + } - return parsed; + return parsed; } async function run(): Promise { - const argv = process.argv.slice(2); - const options = parseArgs(argv); - - const thisFile = fileURLToPath(import.meta.url); - const clientSrcDir = path.dirname(thisFile); - const clientPkgDir = path.resolve(clientSrcDir, '..'); - const defaultServerScript = path.resolve(clientPkgDir, '..', 'server', 'src', 'groupsExample.ts'); - - const serverCommand = options.serverCommand ?? 'pnpm'; - const serverArgs = options.serverArgs ?? ['tsx', defaultServerScript]; - - console.log('======================='); - console.log('Groups filtering client'); - console.log('======================='); - console.log(`Starting stdio server: ${serverCommand} ${serverArgs.join(' ')}`); - - const transport = new StdioClientTransport({ - command: serverCommand, - args: serverArgs, - cwd: clientPkgDir, - stderr: 'inherit' + // ---- Process command-line args ---------------------------------------------------------- + const argv = process.argv.slice(2); + const options = parseArgs(argv); + + const thisFile = fileURLToPath(import.meta.url); + const clientSrcDir = path.dirname(thisFile); + const clientPkgDir = path.resolve(clientSrcDir, '..'); + const defaultServerScript = path.resolve(clientPkgDir, '..', 'server', 'src', 'groupsExample.ts'); + + const serverCommand = options.serverCommand ?? 'pnpm'; + const serverArgs = options.serverArgs ?? ['tsx', defaultServerScript]; + + console.log('======================='); + console.log('Groups filtering client'); + console.log('======================='); + console.log(`Starting stdio server: ${serverCommand} ${serverArgs.join(' ')}`); + + const transport = new StdioClientTransport({ + command: serverCommand, + args: serverArgs, + cwd: clientPkgDir, + stderr: 'inherit' + }); + + const client = new Client({name: 'groups-example-client', version: '1.0.0'}); + await client.connect(transport); + + // ---- Fetch primitives up-front --------------------------------------------------------- + // This example intentionally fetches *all* groups/tools/resources/prompts once at startup. + // The filtering is then performed locally, to demonstrate how a client could build UI + // affordances (search, filters) on top of the server's raw primitive lists. + const [groupsResult, toolsResult, resourcesResult, promptsResult] = await Promise.all([ + client.listGroups(), + client.listTools(), + client.listResources(), + client.listPrompts() + ]); + + const groups = groupsResult.groups; + const tools = toolsResult.tools; + const resources = resourcesResult.resources; + const prompts = promptsResult.prompts; + + // ---- Build the group graph -------------------------------------------------------------- + // We treat group membership on a Group's `_meta[GROUPS_META_KEY]` as “this group is contained + // by the listed parent group(s)”. That lets us build `parentToChildren` for traversal. + const groupNames = new Set(groups.map(g => g.name)); + const parentToChildren = buildParentToChildrenMap(groups); + const knownGroupNames = allKnownGroupNames(groups, parentToChildren); + + // Compute the maximum nesting in the fetched graph so we can validate user-provided depth. + // Note: `computeMaxDepthEdges` counts *edges* (leaf=0, parent->child=1). For a user-facing + // “display depth” we allow one extra level so users can include the deepest group's contents. + const maxDepthEdges = computeMaxDepthEdges(knownGroupNames, parentToChildren); + // User-facing depth includes one extra level so users can choose to include the deepest group's contents. + // Example: if max edge depth is 1 (parent -> child), allow depth up to 2. + const maxDepth = Math.max(1, maxDepthEdges + 1); + let currentDepth = maxDepth; + + console.log(`\nFetched: ${groups.length} groups, ${tools.length} tools, ${resources.length} resources, ${prompts.length} prompts.`); + console.log(`Available groups: ${[...groupNames].toSorted().join(', ')}`); + console.log(`Group display depth: ${currentDepth} (max: ${maxDepth})`); + + const rl = createInterface({input: process.stdin, output: process.stdout}); + + const question = (prompt: string) => + new Promise(resolve => { + rl.question(prompt, answer => resolve(answer.trim())); }); - const client = new Client({ name: 'groups-example-client', version: '1.0.0' }); - await client.connect(transport); - - const [groupsResult, toolsResult, resourcesResult, promptsResult] = await Promise.all([ - client.listGroups(), - client.listTools(), - client.listResources(), - client.listPrompts() - ]); - - const groups = groupsResult.groups; - const tools = toolsResult.tools; - const resources = resourcesResult.resources; - const prompts = promptsResult.prompts; - - const groupNames = new Set(groups.map(g => g.name)); - const parentToChildren = buildParentToChildrenMap(groups); + printHelp(); - console.log(`\nFetched: ${groups.length} groups, ${tools.length} tools, ${resources.length} resources, ${prompts.length} prompts.`); - console.log(`Available groups: ${[...groupNames].toSorted().join(', ')}`); - - const rl = createInterface({ input: process.stdin, output: process.stdout }); - - const question = (prompt: string) => - new Promise(resolve => { - rl.question(prompt, answer => resolve(answer.trim())); - }); - - printHelp(); + while (true) { + let input = await question('\nEnter a command or a list of groups to filter by: '); + if (!input) { + input = 'groups'; + } - while (true) { - let input = await question('\nEnter a command or a list of groups to filter by: '); - if (!input) { - input = 'groups'; - } + const lower = input.toLowerCase(); - const lower = input.toLowerCase(); + // ---- Command: all ------------------------------------------------------------------ + // Show everything, without any local filtering. + if (lower === 'all' || lower === 'a') { + const sortedGroups = [...groups].toSorted((a, b) => a.name.localeCompare(b.name)); + const sortedTools = [...tools].toSorted((a, b) => a.name.localeCompare(b.name)); + const sortedResources = [...resources].toSorted((a, b) => a.name.localeCompare(b.name)); + const sortedPrompts = [...prompts].toSorted((a, b) => a.name.localeCompare(b.name)); - // Handle all command - if (lower === 'all' || lower === 'a') { - const sortedGroups = [...groups].toSorted((a, b) => a.name.localeCompare(b.name)); - const sortedTools = [...tools].toSorted((a, b) => a.name.localeCompare(b.name)); - const sortedResources = [...resources].toSorted((a, b) => a.name.localeCompare(b.name)); - const sortedPrompts = [...prompts].toSorted((a, b) => a.name.localeCompare(b.name)); + if (sortedGroups.length > 0) console.log('\nGroups:'); + console.log(formatBulletList(sortedGroups.map(g => ({name: g.name, description: g.description})))); - console.log('\nGroups:'); - console.log(formatBulletList(sortedGroups.map(g => ({ name: g.name, description: g.description })))); + if (sortedTools.length > 0) console.log('\nTools:'); + console.log(formatBulletList(sortedTools.map(t => ({name: t.name, description: t.description})))); - console.log('\nTools:'); - console.log(formatBulletList(sortedTools.map(t => ({ name: t.name, description: t.description })))); + if (sortedResources.length > 0) console.log('\nResources:'); + console.log(formatBulletList(sortedResources.map(r => ({name: r.name, description: r.description})))); - console.log('\nResources:'); - console.log(formatBulletList(sortedResources.map(r => ({ name: r.name, description: r.description })))); + if (sortedPrompts.length > 0) console.log('\nPrompts:'); + console.log(formatBulletList(sortedPrompts.map(p => ({name: p.name, description: p.description})))); + continue; + } - console.log('\nPrompts:'); - console.log(formatBulletList(sortedPrompts.map(p => ({ name: p.name, description: p.description })))); - continue; - } + // ---- Command: groups ---------------------------------------------------------------- + // List all available groups returned by the server. + if (lower === 'groups' || lower === 'g') { + const sortedGroups = [...groups].toSorted((a, b) => a.name.localeCompare(b.name)); + console.log('\nGroups:'); + console.log(formatBulletList(sortedGroups.map(g => ({name: g.name, description: g.description})))); + continue; + } - // Handle list command - if (lower === 'groups' || lower === 'g') { - const sortedGroups = [...groups].toSorted((a, b) => a.name.localeCompare(b.name)); - console.log('\nGroups:'); - console.log(formatBulletList(sortedGroups.map(g => ({ name: g.name, description: g.description })))); - continue; - } + // ---- Command: depth ----------------------------------------------------------------- + // Controls how far group traversal expands. + // - depth=1: show only immediate children in the “Groups” output, and do NOT include + // the children's tools/resources/prompts. + // - depth=2: show children, and include the children's tools/resources/prompts. + if (lower === 'depth' || lower === 'd' || lower.startsWith('depth ') || lower.startsWith('d ')) { + const parts = input.split(/\s+/).filter(Boolean); + if (parts.length === 1) { + console.log(`Current depth: ${currentDepth} (max: ${maxDepth})`); + continue; + } + + const next = Number.parseInt(parts[1]!, 10); + if (!Number.isFinite(next) || Number.isNaN(next)) { + console.log('Usage: depth [n] (n must be an integer)'); + continue; + } + if (next < 1 || next > maxDepth) { + console.log(`Depth must be between 1 and ${maxDepth}.`); + continue; + } + + currentDepth = next; + console.log(`Group display depth set to ${currentDepth} (max: ${maxDepth}).`); + continue; + } - // Handle help command - if (lower === 'help' || lower === 'h' || lower === '?') { - printHelp(); - continue; - } + // ---- Command: help ------------------------------------------------------------------ + if (lower === 'help' || lower === 'h' || lower === '?') { + printHelp(); + continue; + } - // Handle quit command - if (lower === 'quit' || lower === 'q') { - rl.close(); - await client.close(); - throw new Error('User quit'); - } + // ---- Command: exit ------------------------------------------------------------------ + if (lower === 'exit' || lower === 'e' || lower === 'quit' || lower === 'q') { + rl.close(); + await client.close(); + throw new Error('User quit'); + } - // Handle a group list - const requested = parseGroupList(input); - const unknown = requested.filter(g => !groupNames.has(g)); - if (unknown.length > 0) { - console.log(`Unknown group(s): ${unknown.join(', ')}`); - } + // ---- Treat input as a group list ---------------------------------------------------- + const requested = parseGroupList(input); + const unknown = requested.filter(g => !groupNames.has(g)); + if (unknown.length > 0) { + console.log(`Unknown group(s): ${unknown.join(', ')}`); + } - const validRequested = requested.filter(g => groupNames.has(g)); - if (validRequested.length === 0) { - console.log('No valid groups provided. Type "list" to see available groups.'); - continue; - } + const validRequested = requested.filter(g => groupNames.has(g)); + if (validRequested.length === 0) { + console.log('No valid groups provided. Type "list" to see available groups.'); + continue; + } - const selected = expandWithDescendants(validRequested, parentToChildren); - const groupsToList = expandDescendantsExcludingSelf(validRequested, parentToChildren); + // ---- Depth semantics (important) ---------------------------------------------------- + // We compute TWO different sets: + // 1) `groupsToList`: groups that are *contained by* the requested groups, up to `currentDepth`. + // - Excludes the requested group(s) themselves. + // 2) `includedForContents`: groups whose contents (tools/resources/prompts) are included. + // - Includes the requested group(s) themselves. + // - Traverses only `currentDepth - 1` edges so that `depth=1` doesn't include child contents. + const groupsToList = expandWithinDepth(validRequested, parentToChildren, currentDepth, false); + const includedForContents = expandWithinDepth(validRequested, parentToChildren, Math.max(0, currentDepth - 1), true); - const selectedGroups = groups.filter(g => groupsToList.has(g.name)).toSorted((a, b) => a.name.localeCompare(b.name)); + const selectedGroups = groups.filter(g => groupsToList.has(g.name)).toSorted((a, b) => a.name.localeCompare(b.name)); - const selectedTools = tools - .filter(t => groupMembership(t._meta).some(g => selected.has(g))) - .toSorted((a, b) => a.name.localeCompare(b.name)); + const selectedTools = tools + .filter(t => groupMembership(t._meta).some(g => includedForContents.has(g))) + .toSorted((a, b) => a.name.localeCompare(b.name)); - const selectedResources = resources - .filter(r => groupMembership(r._meta).some(g => selected.has(g))) - .toSorted((a, b) => a.name.localeCompare(b.name)); + const selectedResources = resources + .filter(r => groupMembership(r._meta).some(g => includedForContents.has(g))) + .toSorted((a, b) => a.name.localeCompare(b.name)); - const selectedPrompts = prompts - .filter(p => groupMembership(p._meta).some(g => selected.has(g))) - .toSorted((a, b) => a.name.localeCompare(b.name)); + const selectedPrompts = prompts + .filter(p => groupMembership(p._meta).some(g => includedForContents.has(g))) + .toSorted((a, b) => a.name.localeCompare(b.name)); - console.log('\nGroups:'); - console.log(formatBulletList(selectedGroups.map(g => ({ name: g.name, description: g.description })))); + if (selectedGroups.length > 0) console.log('\nGroups:'); + console.log(formatBulletList(selectedGroups.map(g => ({name: g.name, description: g.description})))); - console.log('\nTools:'); - console.log(formatBulletList(selectedTools.map(t => ({ name: t.name, description: t.description })))); + if (selectedTools.length > 0) console.log('\nTools:'); + console.log(formatBulletList(selectedTools.map(t => ({name: t.name, description: t.description})))); - console.log('\nResources:'); - console.log(formatBulletList(selectedResources.map(r => ({ name: r.name, description: r.description })))); + if (selectedResources.length > 0) console.log('\nResources:'); + console.log(formatBulletList(selectedResources.map(r => ({name: r.name, description: r.description})))); - console.log('\nPrompts:'); - console.log(formatBulletList(selectedPrompts.map(p => ({ name: p.name, description: p.description })))); - } + if (selectedPrompts.length > 0) console.log('\nPrompts:'); + console.log(formatBulletList(selectedPrompts.map(p => ({name: p.name, description: p.description})))); + } } await run(); diff --git a/examples/server/src/groupsExample.ts b/examples/server/src/groupsExample.ts index f8bc8d931..83ee0e834 100644 --- a/examples/server/src/groupsExample.ts +++ b/examples/server/src/groupsExample.ts @@ -10,6 +10,14 @@ const server = new McpServer({ version: '1.0.0' }); +/** + * Helper to attach a single group membership to a primitive. + * + * The groups proposal stores membership in `_meta[GROUPS_META_KEY]`. + * This same mechanism is used for: + * - tools/resources/prompts belonging to a group + * - child groups declaring which parent group(s) they are contained by + */ function metaForGroup(groupName: string) { return { _meta: { @@ -18,7 +26,11 @@ function metaForGroup(groupName: string) { }; } -// Parent groups +// ---- Groups ------------------------------------------------------------------------------ +// This example defines two parent groups (`work`, `communications`) and five child groups. +// Child groups declare containment by including the parent name in `_meta[GROUPS_META_KEY]`. + +// Parent groups (no `_meta` needed; they are roots in this example) server.registerGroup('work', { title: 'Work', description: 'Tools, resources, and prompts related to day-to-day work.' @@ -29,7 +41,7 @@ server.registerGroup('communications', { description: 'Tools, resources, and prompts related to messaging and scheduling.' }); -// Child groups +// Child groups (each one is “contained by” a parent group) server.registerGroup('spreadsheets', { title: 'Spreadsheets', description: 'Spreadsheet-like operations: create sheets, add rows, and do quick calculations.', @@ -70,7 +82,9 @@ server.registerGroup('calendar', { } }); -// ===== Tools ===== +// ---- Tools ------------------------------------------------------------------------------- +// Tools are assigned to a group by including `_meta[GROUPS_META_KEY]`. +// In this example they are simple stubs that return a confirmation string. // Email tools server.registerTool( From c32a93d21f6d68ce115602e30e5a9332d148c530 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 24 Jan 2026 17:50:11 -0500 Subject: [PATCH 11/11] formatting --- examples/client/src/groupsExampleClient.ts | 754 ++++++++++----------- 1 file changed, 377 insertions(+), 377 deletions(-) diff --git a/examples/client/src/groupsExampleClient.ts b/examples/client/src/groupsExampleClient.ts index 82c6d3598..9ff8cf7be 100644 --- a/examples/client/src/groupsExampleClient.ts +++ b/examples/client/src/groupsExampleClient.ts @@ -6,11 +6,11 @@ import path from 'node:path'; import process from 'node:process'; -import {createInterface} from 'node:readline'; -import {fileURLToPath} from 'node:url'; +import { createInterface } from 'node:readline'; +import { fileURLToPath } from 'node:url'; -import type {Group} from '@modelcontextprotocol/client'; -import {Client, GROUPS_META_KEY, StdioClientTransport} from '@modelcontextprotocol/client'; +import type { Group } from '@modelcontextprotocol/client'; +import { Client, GROUPS_META_KEY, StdioClientTransport } from '@modelcontextprotocol/client'; type GroupName = string; @@ -22,10 +22,10 @@ type GroupName = string; * - `communications work` */ function parseGroupList(input: string): string[] { - return input - .split(/[\s,]+/) - .map(s => s.trim()) - .filter(Boolean); + return input + .split(/[\s,]+/) + .map(s => s.trim()) + .filter(Boolean); } /** @@ -36,30 +36,30 @@ function parseGroupList(input: string): string[] { * - Non-string entries are ignored. */ function groupMembership(meta: unknown): string[] { - // `_meta` is defined as an open-ended metadata object on primitives, but it may be: - // - missing entirely - // - `null` - // - some other non-object value - // In all of those cases we treat it as “no group membership information available” - if (!meta || typeof meta !== 'object') { - return []; - } - - // We only need dictionary-style access (`record[key]`), so we cast to a generic record. - // This is intentionally tolerant: the server may include other meta keys we don't know about. - const record = meta as Record; - - // The groups proposal stores membership at `_meta[GROUPS_META_KEY]`. - // Convention: - // - For tools/resources/prompts: list of groups that primitive belongs to. - // - For groups themselves: list of parent groups that *contain* this group. - const value = record[GROUPS_META_KEY]; - if (!Array.isArray(value)) { - return []; - } - - // Be defensive: only keep string entries (ignore malformed values like numbers/objects). - return value.filter((v): v is string => typeof v === 'string'); + // `_meta` is defined as an open-ended metadata object on primitives, but it may be: + // - missing entirely + // - `null` + // - some other non-object value + // In all of those cases we treat it as “no group membership information available” + if (!meta || typeof meta !== 'object') { + return []; + } + + // We only need dictionary-style access (`record[key]`), so we cast to a generic record. + // This is intentionally tolerant: the server may include other meta keys we don't know about. + const record = meta as Record; + + // The groups proposal stores membership at `_meta[GROUPS_META_KEY]`. + // Convention: + // - For tools/resources/prompts: list of groups that primitive belongs to. + // - For groups themselves: list of parent groups that *contain* this group. + const value = record[GROUPS_META_KEY]; + if (!Array.isArray(value)) { + return []; + } + + // Be defensive: only keep string entries (ignore malformed values like numbers/objects). + return value.filter((v): v is string => typeof v === 'string'); } /** @@ -69,26 +69,26 @@ function groupMembership(meta: unknown): string[] { * So we invert that relationship into a `parentToChildren` map to make traversal easier. */ function buildParentToChildrenMap(groups: Group[]): Map> { - const map = new Map>(); - - for (const group of groups) { - // Each *child* group declares its parent group(s) via `_meta[GROUPS_META_KEY]`. - // Example: if group `email` has `_meta[GROUPS_META_KEY]=['communications']`, then - // `communications` *contains* `email`. - const parents = groupMembership(group._meta); - for (const parent of parents) { - // Build an adjacency list (parent -> children) so we can traverse “down” the graph. - // We store children in a Set to: - // - naturally dedupe if the server repeats membership - // - avoid multiple queue entries later during traversal - if (!map.has(parent)) { - map.set(parent, new Set()); - } - map.get(parent)!.add(group.name); + const map = new Map>(); + + for (const group of groups) { + // Each *child* group declares its parent group(s) via `_meta[GROUPS_META_KEY]`. + // Example: if group `email` has `_meta[GROUPS_META_KEY]=['communications']`, then + // `communications` *contains* `email`. + const parents = groupMembership(group._meta); + for (const parent of parents) { + // Build an adjacency list (parent -> children) so we can traverse “down” the graph. + // We store children in a Set to: + // - naturally dedupe if the server repeats membership + // - avoid multiple queue entries later during traversal + if (!map.has(parent)) { + map.set(parent, new Set()); + } + map.get(parent)!.add(group.name); + } } - } - return map; + return map; } /** @@ -98,16 +98,16 @@ function buildParentToChildrenMap(groups: Group[]): Map>): Set { - const names = new Set(); + const names = new Set(); - for (const g of groups) { - names.add(g.name); - } - for (const parent of parentToChildren.keys()) { - names.add(parent); - } + for (const g of groups) { + names.add(g.name); + } + for (const parent of parentToChildren.keys()) { + names.add(parent); + } - return names; + return names; } /** @@ -120,62 +120,62 @@ function allKnownGroupNames(groups: Group[], parentToChildren: Map, parentToChildren: Map>): number { - // We want a *global* maximum nesting depth to validate the user's `depth` setting. - // This is a graph problem (not necessarily a tree): a child can have multiple parents. - // - // We compute “depth in edges” rather than “depth in nodes”: - // - leaf = 0 - // - parent -> child = 1 - // This aligns cleanly with traversal where each step consumes one edge. - const memo = new Map(); - - const dfs = (node: GroupName, path: Set): number => { - // Memoization: once we've computed the best descendant depth for a node, - // we can reuse it across different starting points. - const cached = memo.get(node); - if (cached !== undefined) { - return cached; - } + // We want a *global* maximum nesting depth to validate the user's `depth` setting. + // This is a graph problem (not necessarily a tree): a child can have multiple parents. + // + // We compute “depth in edges” rather than “depth in nodes”: + // - leaf = 0 + // - parent -> child = 1 + // This aligns cleanly with traversal where each step consumes one edge. + const memo = new Map(); + + const dfs = (node: GroupName, path: Set): number => { + // Memoization: once we've computed the best descendant depth for a node, + // we can reuse it across different starting points. + const cached = memo.get(node); + if (cached !== undefined) { + return cached; + } - // Cycle safety: group graphs *should* be acyclic, but we must not assume that. - // If we re-enter a node already on the current recursion path, treat it as a leaf - // for the purpose of depth calculation and stop descending. - if (path.has(node)) { - return 0; - } + // Cycle safety: group graphs *should* be acyclic, but we must not assume that. + // If we re-enter a node already on the current recursion path, treat it as a leaf + // for the purpose of depth calculation and stop descending. + if (path.has(node)) { + return 0; + } - // Track the active DFS stack so we can detect cycles specific to this path. - path.add(node); - const children = parentToChildren.get(node); - let best = 0; - if (children) { - for (const child of children) { - // If a direct child is already on the active path, we'd form a cycle. - // Skip it; other children may still extend depth. - if (path.has(child)) { - continue; + // Track the active DFS stack so we can detect cycles specific to this path. + path.add(node); + const children = parentToChildren.get(node); + let best = 0; + if (children) { + for (const child of children) { + // If a direct child is already on the active path, we'd form a cycle. + // Skip it; other children may still extend depth. + if (path.has(child)) { + continue; + } + + // “1 + dfs(child)” accounts for the edge from `node` to `child`. + best = Math.max(best, 1 + dfs(child, path)); + } } - // “1 + dfs(child)” accounts for the edge from `node` to `child`. - best = Math.max(best, 1 + dfs(child, path)); - } - } + // Pop from recursion stack before returning to the caller. + path.delete(node); + + // Cache the computed best depth for this node. + memo.set(node, best); + return best; + }; - // Pop from recursion stack before returning to the caller. - path.delete(node); - - // Cache the computed best depth for this node. - memo.set(node, best); - return best; - }; - - // Some parent groups might only exist as names referenced in `_meta` and not as full `Group` objects. - // That's why the caller passes `allGroups` rather than just `groups.map(g => g.name)`. - let max = 0; - for (const g of allGroups) { - max = Math.max(max, dfs(g, new Set())); - } - return max; + // Some parent groups might only exist as names referenced in `_meta` and not as full `Group` objects. + // That's why the caller passes `allGroups` rather than just `groups.map(g => g.name)`. + let max = 0; + for (const g of allGroups) { + max = Math.max(max, dfs(g, new Set())); + } + return max; } /** @@ -190,322 +190,322 @@ function computeMaxDepthEdges(allGroups: Iterable, parentToChildren: * “Groups” section (a group doesn't “contain itself”). */ function expandWithinDepth( - selected: string[], - parentToChildren: Map>, - depthEdges: number, - includeSelf: boolean + selected: string[], + parentToChildren: Map>, + depthEdges: number, + includeSelf: boolean ): Set { - // `out` accumulates the groups we will return. - const out = new Set(); - - // `visited` ensures we evaluate any group at most once. This makes traversal: - // - cycle-safe (won't loop forever) - // - efficient (won't expand the same subgraph repeatedly) - const visited = new Set(); - - // We do a breadth-first traversal so “remaining depth” is easy to manage. - // Each queue item carries how many edges are still allowed from that node. - const queue: Array<{ name: GroupName; remaining: number }> = []; - - for (const g of selected) { - // Optionally include the selected group(s) themselves. - // For the CLI's “Groups” section we usually exclude these so a group doesn't “contain itself”. - if (includeSelf) { - out.add(g); - } + // `out` accumulates the groups we will return. + const out = new Set(); - // Seed traversal from each selected group, but only once per unique group. - if (!visited.has(g)) { - visited.add(g); - queue.push({name: g, remaining: depthEdges}); - } - } + // `visited` ensures we evaluate any group at most once. This makes traversal: + // - cycle-safe (won't loop forever) + // - efficient (won't expand the same subgraph repeatedly) + const visited = new Set(); - while (queue.length > 0) { - // Take the next node to expand. - const {name: current, remaining} = queue.shift()!; + // We do a breadth-first traversal so “remaining depth” is easy to manage. + // Each queue item carries how many edges are still allowed from that node. + const queue: Array<{ name: GroupName; remaining: number }> = []; - // No remaining budget means we stop expanding children from this node. - if (remaining <= 0) { - continue; + for (const g of selected) { + // Optionally include the selected group(s) themselves. + // For the CLI's “Groups” section we usually exclude these so a group doesn't “contain itself”. + if (includeSelf) { + out.add(g); + } + + // Seed traversal from each selected group, but only once per unique group. + if (!visited.has(g)) { + visited.add(g); + queue.push({ name: g, remaining: depthEdges }); + } } - const children = parentToChildren.get(current); + while (queue.length > 0) { + // Take the next node to expand. + const { name: current, remaining } = queue.shift()!; - // Missing entry means the node is a leaf (or unknown to our graph). Nothing to expand. - if (!children) { - continue; - } - for (const child of children) { - // A contained group is always included in the output set. - out.add(child); - - // Only enqueue the child if we haven't expanded it already. - // Note: we still add `child` to `out` even if visited, because it may be a child - // of multiple parents and should appear as “contained” regardless. - if (!visited.has(child)) { - visited.add(child); - queue.push({name: child, remaining: remaining - 1}); - } + // No remaining budget means we stop expanding children from this node. + if (remaining <= 0) { + continue; + } + + const children = parentToChildren.get(current); + + // Missing entry means the node is a leaf (or unknown to our graph). Nothing to expand. + if (!children) { + continue; + } + for (const child of children) { + // A contained group is always included in the output set. + out.add(child); + + // Only enqueue the child if we haven't expanded it already. + // Note: we still add `child` to `out` even if visited, because it may be a child + // of multiple parents and should appear as “contained” regardless. + if (!visited.has(child)) { + visited.add(child); + queue.push({ name: child, remaining: remaining - 1 }); + } + } } - } - if (!includeSelf) { - // A second safety-net: even if traversal re-adds a selected group through an unusual - // cyclic or multi-parent configuration, ensure we don't list requested groups as “contained”. - for (const g of selected) { - out.delete(g); + if (!includeSelf) { + // A second safety-net: even if traversal re-adds a selected group through an unusual + // cyclic or multi-parent configuration, ensure we don't list requested groups as “contained”. + for (const g of selected) { + out.delete(g); + } } - } - return out; + return out; } function formatBulletList(items: Array<{ name: string; description?: string }>): string { - if (items.length === 0) { - return '(none)'; - } - - return items - .map(i => { - const desc = i.description ? ` — ${i.description}` : ''; - return `- ${i.name}${desc}`; - }) - .join('\n'); + if (items.length === 0) { + return '(none)'; + } + + return items + .map(i => { + const desc = i.description ? ` — ${i.description}` : ''; + return `- ${i.name}${desc}`; + }) + .join('\n'); } function printHelp() { - console.log('\nCommands:'); - console.log(' all (a) List all groups, tools, resources, and prompts'); - console.log(' depth (d) [n] Show or set group display depth (1..max)'); - console.log(' groups (g/enter) List available groups '); - console.log(' help (h) Show this help'); - console.log(' exit (e/quit/q) Quit'); - console.log(' Filter by one or more groups (comma or space-separated)'); + console.log('\nCommands:'); + console.log(' all (a) List all groups, tools, resources, and prompts'); + console.log(' depth (d) [n] Show or set group display depth (1..max)'); + console.log(' groups (g/enter) List available groups '); + console.log(' help (h) Show this help'); + console.log(' exit (e/quit/q) Quit'); + console.log(' Filter by one or more groups (comma or space-separated)'); } function parseArgs(argv: string[]) { - const parsed: { serverCommand?: string; serverArgs?: string[] } = {}; - - for (let i = 0; i < argv.length; i++) { - const arg = argv[i]; - if (arg === '--server-command' && argv[i + 1]) { - parsed.serverCommand = argv[i + 1]!; - i++; - continue; - } - if (arg === '--server-args' && argv[i + 1]) { - // A single string that will be split on whitespace. Intended for simple use. - parsed.serverArgs = argv[i + 1]!.split(/\s+/).filter(Boolean); - i++; - continue; + const parsed: { serverCommand?: string; serverArgs?: string[] } = {}; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--server-command' && argv[i + 1]) { + parsed.serverCommand = argv[i + 1]!; + i++; + continue; + } + if (arg === '--server-args' && argv[i + 1]) { + // A single string that will be split on whitespace. Intended for simple use. + parsed.serverArgs = argv[i + 1]!.split(/\s+/).filter(Boolean); + i++; + continue; + } } - } - return parsed; + return parsed; } async function run(): Promise { - // ---- Process command-line args ---------------------------------------------------------- - const argv = process.argv.slice(2); - const options = parseArgs(argv); - - const thisFile = fileURLToPath(import.meta.url); - const clientSrcDir = path.dirname(thisFile); - const clientPkgDir = path.resolve(clientSrcDir, '..'); - const defaultServerScript = path.resolve(clientPkgDir, '..', 'server', 'src', 'groupsExample.ts'); - - const serverCommand = options.serverCommand ?? 'pnpm'; - const serverArgs = options.serverArgs ?? ['tsx', defaultServerScript]; - - console.log('======================='); - console.log('Groups filtering client'); - console.log('======================='); - console.log(`Starting stdio server: ${serverCommand} ${serverArgs.join(' ')}`); - - const transport = new StdioClientTransport({ - command: serverCommand, - args: serverArgs, - cwd: clientPkgDir, - stderr: 'inherit' - }); - - const client = new Client({name: 'groups-example-client', version: '1.0.0'}); - await client.connect(transport); - - // ---- Fetch primitives up-front --------------------------------------------------------- - // This example intentionally fetches *all* groups/tools/resources/prompts once at startup. - // The filtering is then performed locally, to demonstrate how a client could build UI - // affordances (search, filters) on top of the server's raw primitive lists. - const [groupsResult, toolsResult, resourcesResult, promptsResult] = await Promise.all([ - client.listGroups(), - client.listTools(), - client.listResources(), - client.listPrompts() - ]); - - const groups = groupsResult.groups; - const tools = toolsResult.tools; - const resources = resourcesResult.resources; - const prompts = promptsResult.prompts; - - // ---- Build the group graph -------------------------------------------------------------- - // We treat group membership on a Group's `_meta[GROUPS_META_KEY]` as “this group is contained - // by the listed parent group(s)”. That lets us build `parentToChildren` for traversal. - const groupNames = new Set(groups.map(g => g.name)); - const parentToChildren = buildParentToChildrenMap(groups); - const knownGroupNames = allKnownGroupNames(groups, parentToChildren); - - // Compute the maximum nesting in the fetched graph so we can validate user-provided depth. - // Note: `computeMaxDepthEdges` counts *edges* (leaf=0, parent->child=1). For a user-facing - // “display depth” we allow one extra level so users can include the deepest group's contents. - const maxDepthEdges = computeMaxDepthEdges(knownGroupNames, parentToChildren); - // User-facing depth includes one extra level so users can choose to include the deepest group's contents. - // Example: if max edge depth is 1 (parent -> child), allow depth up to 2. - const maxDepth = Math.max(1, maxDepthEdges + 1); - let currentDepth = maxDepth; - - console.log(`\nFetched: ${groups.length} groups, ${tools.length} tools, ${resources.length} resources, ${prompts.length} prompts.`); - console.log(`Available groups: ${[...groupNames].toSorted().join(', ')}`); - console.log(`Group display depth: ${currentDepth} (max: ${maxDepth})`); - - const rl = createInterface({input: process.stdin, output: process.stdout}); - - const question = (prompt: string) => - new Promise(resolve => { - rl.question(prompt, answer => resolve(answer.trim())); + // ---- Process command-line args ---------------------------------------------------------- + const argv = process.argv.slice(2); + const options = parseArgs(argv); + + const thisFile = fileURLToPath(import.meta.url); + const clientSrcDir = path.dirname(thisFile); + const clientPkgDir = path.resolve(clientSrcDir, '..'); + const defaultServerScript = path.resolve(clientPkgDir, '..', 'server', 'src', 'groupsExample.ts'); + + const serverCommand = options.serverCommand ?? 'pnpm'; + const serverArgs = options.serverArgs ?? ['tsx', defaultServerScript]; + + console.log('======================='); + console.log('Groups filtering client'); + console.log('======================='); + console.log(`Starting stdio server: ${serverCommand} ${serverArgs.join(' ')}`); + + const transport = new StdioClientTransport({ + command: serverCommand, + args: serverArgs, + cwd: clientPkgDir, + stderr: 'inherit' }); - printHelp(); - - while (true) { - let input = await question('\nEnter a command or a list of groups to filter by: '); - if (!input) { - input = 'groups'; - } + const client = new Client({ name: 'groups-example-client', version: '1.0.0' }); + await client.connect(transport); + + // ---- Fetch primitives up-front --------------------------------------------------------- + // This example intentionally fetches *all* groups/tools/resources/prompts once at startup. + // The filtering is then performed locally, to demonstrate how a client could build UI + // affordances (search, filters) on top of the server's raw primitive lists. + const [groupsResult, toolsResult, resourcesResult, promptsResult] = await Promise.all([ + client.listGroups(), + client.listTools(), + client.listResources(), + client.listPrompts() + ]); + + const groups = groupsResult.groups; + const tools = toolsResult.tools; + const resources = resourcesResult.resources; + const prompts = promptsResult.prompts; + + // ---- Build the group graph -------------------------------------------------------------- + // We treat group membership on a Group's `_meta[GROUPS_META_KEY]` as “this group is contained + // by the listed parent group(s)”. That lets us build `parentToChildren` for traversal. + const groupNames = new Set(groups.map(g => g.name)); + const parentToChildren = buildParentToChildrenMap(groups); + const knownGroupNames = allKnownGroupNames(groups, parentToChildren); + + // Compute the maximum nesting in the fetched graph so we can validate user-provided depth. + // Note: `computeMaxDepthEdges` counts *edges* (leaf=0, parent->child=1). For a user-facing + // “display depth” we allow one extra level so users can include the deepest group's contents. + const maxDepthEdges = computeMaxDepthEdges(knownGroupNames, parentToChildren); + // User-facing depth includes one extra level so users can choose to include the deepest group's contents. + // Example: if max edge depth is 1 (parent -> child), allow depth up to 2. + const maxDepth = Math.max(1, maxDepthEdges + 1); + let currentDepth = maxDepth; + + console.log(`\nFetched: ${groups.length} groups, ${tools.length} tools, ${resources.length} resources, ${prompts.length} prompts.`); + console.log(`Available groups: ${[...groupNames].toSorted().join(', ')}`); + console.log(`Group display depth: ${currentDepth} (max: ${maxDepth})`); + + const rl = createInterface({ input: process.stdin, output: process.stdout }); + + const question = (prompt: string) => + new Promise(resolve => { + rl.question(prompt, answer => resolve(answer.trim())); + }); + + printHelp(); + + while (true) { + let input = await question('\nEnter a command or a list of groups to filter by: '); + if (!input) { + input = 'groups'; + } - const lower = input.toLowerCase(); + const lower = input.toLowerCase(); - // ---- Command: all ------------------------------------------------------------------ - // Show everything, without any local filtering. - if (lower === 'all' || lower === 'a') { - const sortedGroups = [...groups].toSorted((a, b) => a.name.localeCompare(b.name)); - const sortedTools = [...tools].toSorted((a, b) => a.name.localeCompare(b.name)); - const sortedResources = [...resources].toSorted((a, b) => a.name.localeCompare(b.name)); - const sortedPrompts = [...prompts].toSorted((a, b) => a.name.localeCompare(b.name)); + // ---- Command: all ------------------------------------------------------------------ + // Show everything, without any local filtering. + if (lower === 'all' || lower === 'a') { + const sortedGroups = [...groups].toSorted((a, b) => a.name.localeCompare(b.name)); + const sortedTools = [...tools].toSorted((a, b) => a.name.localeCompare(b.name)); + const sortedResources = [...resources].toSorted((a, b) => a.name.localeCompare(b.name)); + const sortedPrompts = [...prompts].toSorted((a, b) => a.name.localeCompare(b.name)); - if (sortedGroups.length > 0) console.log('\nGroups:'); - console.log(formatBulletList(sortedGroups.map(g => ({name: g.name, description: g.description})))); + if (sortedGroups.length > 0) console.log('\nGroups:'); + console.log(formatBulletList(sortedGroups.map(g => ({ name: g.name, description: g.description })))); - if (sortedTools.length > 0) console.log('\nTools:'); - console.log(formatBulletList(sortedTools.map(t => ({name: t.name, description: t.description})))); + if (sortedTools.length > 0) console.log('\nTools:'); + console.log(formatBulletList(sortedTools.map(t => ({ name: t.name, description: t.description })))); - if (sortedResources.length > 0) console.log('\nResources:'); - console.log(formatBulletList(sortedResources.map(r => ({name: r.name, description: r.description})))); + if (sortedResources.length > 0) console.log('\nResources:'); + console.log(formatBulletList(sortedResources.map(r => ({ name: r.name, description: r.description })))); - if (sortedPrompts.length > 0) console.log('\nPrompts:'); - console.log(formatBulletList(sortedPrompts.map(p => ({name: p.name, description: p.description})))); - continue; - } + if (sortedPrompts.length > 0) console.log('\nPrompts:'); + console.log(formatBulletList(sortedPrompts.map(p => ({ name: p.name, description: p.description })))); + continue; + } - // ---- Command: groups ---------------------------------------------------------------- - // List all available groups returned by the server. - if (lower === 'groups' || lower === 'g') { - const sortedGroups = [...groups].toSorted((a, b) => a.name.localeCompare(b.name)); - console.log('\nGroups:'); - console.log(formatBulletList(sortedGroups.map(g => ({name: g.name, description: g.description})))); - continue; - } + // ---- Command: groups ---------------------------------------------------------------- + // List all available groups returned by the server. + if (lower === 'groups' || lower === 'g') { + const sortedGroups = [...groups].toSorted((a, b) => a.name.localeCompare(b.name)); + console.log('\nGroups:'); + console.log(formatBulletList(sortedGroups.map(g => ({ name: g.name, description: g.description })))); + continue; + } - // ---- Command: depth ----------------------------------------------------------------- - // Controls how far group traversal expands. - // - depth=1: show only immediate children in the “Groups” output, and do NOT include - // the children's tools/resources/prompts. - // - depth=2: show children, and include the children's tools/resources/prompts. - if (lower === 'depth' || lower === 'd' || lower.startsWith('depth ') || lower.startsWith('d ')) { - const parts = input.split(/\s+/).filter(Boolean); - if (parts.length === 1) { - console.log(`Current depth: ${currentDepth} (max: ${maxDepth})`); - continue; - } - - const next = Number.parseInt(parts[1]!, 10); - if (!Number.isFinite(next) || Number.isNaN(next)) { - console.log('Usage: depth [n] (n must be an integer)'); - continue; - } - if (next < 1 || next > maxDepth) { - console.log(`Depth must be between 1 and ${maxDepth}.`); - continue; - } - - currentDepth = next; - console.log(`Group display depth set to ${currentDepth} (max: ${maxDepth}).`); - continue; - } + // ---- Command: depth ----------------------------------------------------------------- + // Controls how far group traversal expands. + // - depth=1: show only immediate children in the “Groups” output, and do NOT include + // the children's tools/resources/prompts. + // - depth=2: show children, and include the children's tools/resources/prompts. + if (lower === 'depth' || lower === 'd' || lower.startsWith('depth ') || lower.startsWith('d ')) { + const parts = input.split(/\s+/).filter(Boolean); + if (parts.length === 1) { + console.log(`Current depth: ${currentDepth} (max: ${maxDepth})`); + continue; + } + + const next = Number.parseInt(parts[1]!, 10); + if (!Number.isFinite(next) || Number.isNaN(next)) { + console.log('Usage: depth [n] (n must be an integer)'); + continue; + } + if (next < 1 || next > maxDepth) { + console.log(`Depth must be between 1 and ${maxDepth}.`); + continue; + } + + currentDepth = next; + console.log(`Group display depth set to ${currentDepth} (max: ${maxDepth}).`); + continue; + } - // ---- Command: help ------------------------------------------------------------------ - if (lower === 'help' || lower === 'h' || lower === '?') { - printHelp(); - continue; - } + // ---- Command: help ------------------------------------------------------------------ + if (lower === 'help' || lower === 'h' || lower === '?') { + printHelp(); + continue; + } - // ---- Command: exit ------------------------------------------------------------------ - if (lower === 'exit' || lower === 'e' || lower === 'quit' || lower === 'q') { - rl.close(); - await client.close(); - throw new Error('User quit'); - } + // ---- Command: exit ------------------------------------------------------------------ + if (lower === 'exit' || lower === 'e' || lower === 'quit' || lower === 'q') { + rl.close(); + await client.close(); + throw new Error('User quit'); + } - // ---- Treat input as a group list ---------------------------------------------------- - const requested = parseGroupList(input); - const unknown = requested.filter(g => !groupNames.has(g)); - if (unknown.length > 0) { - console.log(`Unknown group(s): ${unknown.join(', ')}`); - } + // ---- Treat input as a group list ---------------------------------------------------- + const requested = parseGroupList(input); + const unknown = requested.filter(g => !groupNames.has(g)); + if (unknown.length > 0) { + console.log(`Unknown group(s): ${unknown.join(', ')}`); + } - const validRequested = requested.filter(g => groupNames.has(g)); - if (validRequested.length === 0) { - console.log('No valid groups provided. Type "list" to see available groups.'); - continue; - } + const validRequested = requested.filter(g => groupNames.has(g)); + if (validRequested.length === 0) { + console.log('No valid groups provided. Type "list" to see available groups.'); + continue; + } - // ---- Depth semantics (important) ---------------------------------------------------- - // We compute TWO different sets: - // 1) `groupsToList`: groups that are *contained by* the requested groups, up to `currentDepth`. - // - Excludes the requested group(s) themselves. - // 2) `includedForContents`: groups whose contents (tools/resources/prompts) are included. - // - Includes the requested group(s) themselves. - // - Traverses only `currentDepth - 1` edges so that `depth=1` doesn't include child contents. - const groupsToList = expandWithinDepth(validRequested, parentToChildren, currentDepth, false); - const includedForContents = expandWithinDepth(validRequested, parentToChildren, Math.max(0, currentDepth - 1), true); + // ---- Depth semantics (important) ---------------------------------------------------- + // We compute TWO different sets: + // 1) `groupsToList`: groups that are *contained by* the requested groups, up to `currentDepth`. + // - Excludes the requested group(s) themselves. + // 2) `includedForContents`: groups whose contents (tools/resources/prompts) are included. + // - Includes the requested group(s) themselves. + // - Traverses only `currentDepth - 1` edges so that `depth=1` doesn't include child contents. + const groupsToList = expandWithinDepth(validRequested, parentToChildren, currentDepth, false); + const includedForContents = expandWithinDepth(validRequested, parentToChildren, Math.max(0, currentDepth - 1), true); - const selectedGroups = groups.filter(g => groupsToList.has(g.name)).toSorted((a, b) => a.name.localeCompare(b.name)); + const selectedGroups = groups.filter(g => groupsToList.has(g.name)).toSorted((a, b) => a.name.localeCompare(b.name)); - const selectedTools = tools - .filter(t => groupMembership(t._meta).some(g => includedForContents.has(g))) - .toSorted((a, b) => a.name.localeCompare(b.name)); + const selectedTools = tools + .filter(t => groupMembership(t._meta).some(g => includedForContents.has(g))) + .toSorted((a, b) => a.name.localeCompare(b.name)); - const selectedResources = resources - .filter(r => groupMembership(r._meta).some(g => includedForContents.has(g))) - .toSorted((a, b) => a.name.localeCompare(b.name)); + const selectedResources = resources + .filter(r => groupMembership(r._meta).some(g => includedForContents.has(g))) + .toSorted((a, b) => a.name.localeCompare(b.name)); - const selectedPrompts = prompts - .filter(p => groupMembership(p._meta).some(g => includedForContents.has(g))) - .toSorted((a, b) => a.name.localeCompare(b.name)); + const selectedPrompts = prompts + .filter(p => groupMembership(p._meta).some(g => includedForContents.has(g))) + .toSorted((a, b) => a.name.localeCompare(b.name)); - if (selectedGroups.length > 0) console.log('\nGroups:'); - console.log(formatBulletList(selectedGroups.map(g => ({name: g.name, description: g.description})))); + if (selectedGroups.length > 0) console.log('\nGroups:'); + console.log(formatBulletList(selectedGroups.map(g => ({ name: g.name, description: g.description })))); - if (selectedTools.length > 0) console.log('\nTools:'); - console.log(formatBulletList(selectedTools.map(t => ({name: t.name, description: t.description})))); + if (selectedTools.length > 0) console.log('\nTools:'); + console.log(formatBulletList(selectedTools.map(t => ({ name: t.name, description: t.description })))); - if (selectedResources.length > 0) console.log('\nResources:'); - console.log(formatBulletList(selectedResources.map(r => ({name: r.name, description: r.description})))); + if (selectedResources.length > 0) console.log('\nResources:'); + console.log(formatBulletList(selectedResources.map(r => ({ name: r.name, description: r.description })))); - if (selectedPrompts.length > 0) console.log('\nPrompts:'); - console.log(formatBulletList(selectedPrompts.map(p => ({name: p.name, description: p.description})))); - } + if (selectedPrompts.length > 0) console.log('\nPrompts:'); + console.log(formatBulletList(selectedPrompts.map(p => ({ name: p.name, description: p.description })))); + } } await run();