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..9ff8cf7be --- /dev/null +++ b/examples/client/src/groupsExampleClient.ts @@ -0,0 +1,511 @@ +// 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; + +/** + * 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); +} + +/** + * 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[] { + // `_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) { + // 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; +} + +/** + * 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; +} + +/** + * 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; + } + + // 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)); + } + } + + // 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; +} + +/** + * 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 }); + } + } + + while (queue.length > 0) { + // Take the next node to expand. + const { name: current, remaining } = queue.shift()!; + + // 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); + } + } + + 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(' 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; + } + } + + 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())); + }); + + 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(); + + // ---- 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 (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 (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: 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: 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(', ')}`); + } + + 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); + + 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 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)); + + 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 (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 })))); + } +} + +await run(); 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..83ee0e834 --- /dev/null +++ b/examples/server/src/groupsExample.ts @@ -0,0 +1,481 @@ +// 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' +}); + +/** + * 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: { + [GROUPS_META_KEY]: [groupName] + } + }; +} + +// ---- 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.' +}); + +server.registerGroup('communications', { + title: 'Communications', + description: 'Tools, resources, and prompts related to messaging and scheduling.' +}); + +// 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.', + _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 ------------------------------------------------------------------------------- +// 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( + '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.'); +} + +await main(); diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 2d55a9a01..98c864d61 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, @@ -51,10 +52,12 @@ import { ErrorCode, getObjectShape, GetPromptResultSchema, + GroupListChangedNotificationSchema, InitializeResultSchema, isZ4Schema, LATEST_PROTOCOL_VERSION, ListChangedOptionsBaseSchema, + ListGroupsResultSchema, ListPromptsResultSchema, ListResourcesResultSchema, ListResourceTemplatesResultSchema, @@ -298,6 +301,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; + }); + } } /** @@ -735,6 +745,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 def841832..694b7cd7e 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, @@ -1273,6 +1274,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 d3e404c58..58ffd138e 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. */ @@ -66,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 + */ +export 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. */ @@ -398,6 +434,27 @@ 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(), + + /** + * Metadata possibly including a group list + */ + _meta: GroupMetaSchema +}); + const FormElicitationCapabilitySchema = z.intersection( z.object({ applyDefaults: z.boolean().optional() @@ -604,6 +661,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. */ @@ -732,7 +800,12 @@ 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()), + + /** + * Metadata possibly including a group list + */ + _meta: GroupMetaSchema }); /** @@ -817,6 +890,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. @@ -870,31 +965,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. */ @@ -924,10 +994,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({})) + _meta: GroupMetaSchema }); /** @@ -959,10 +1028,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 }); /** @@ -1100,10 +1168,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({})) + _meta: GroupMetaSchema }); /** @@ -1421,10 +1488,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.record(z.string(), z.unknown()).optional() + _meta: GroupMetaSchema }); /** @@ -1594,6 +1660,10 @@ export type ListChangedHandlers = { * Handler for resource list changes. */ resources?: ListChangedOptions; + /** + * Handler for group list changes. + */ + groups?: ListChangedOptions; }; /* Logging */ @@ -2237,6 +2307,7 @@ export const ClientRequestSchema = z.union([ UnsubscribeRequestSchema, CallToolRequestSchema, ListToolsRequestSchema, + ListGroupsRequestSchema, GetTaskRequestSchema, GetTaskPayloadRequestSchema, ListTasksRequestSchema, @@ -2282,6 +2353,7 @@ export const ServerNotificationSchema = z.union([ ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema, PromptListChangedNotificationSchema, + GroupListChangedNotificationSchema, TaskStatusNotificationSchema, ElicitationCompleteNotificationSchema ]); @@ -2297,6 +2369,7 @@ export const ServerResultSchema = z.union([ ReadResourceResultSchema, CallToolResultSchema, ListToolsResultSchema, + ListGroupsResultSchema, GetTaskResultSchema, ListTasksResultSchema, CreateTaskResultSchema @@ -2596,6 +2669,13 @@ export type ListRootsRequest = Infer; export type ListRootsResult = Infer; export type RootsListChangedNotification = Infer; +/* Groups */ +export type Group = Infer; +export type GroupMeta = 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 975cca257..708f927a6 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -9,7 +9,10 @@ import type { CompleteResult, CreateTaskResult, GetPromptResult, + Group, + GroupMeta, Implementation, + ListGroupsResult, ListPromptsResult, ListResourcesResult, ListToolsResult, @@ -45,6 +48,7 @@ import { GetPromptRequestSchema, getSchemaDescription, isSchemaOptional, + ListGroupsRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, @@ -83,6 +87,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) { @@ -484,6 +489,7 @@ export class McpServer { } private _resourceHandlersInitialized = false; + private _groupHandlersInitialized = false; private setResourceRequestHandlers() { if (this._resourceHandlersInitialized) { @@ -566,6 +572,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; @@ -590,7 +629,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 }; }) }) @@ -733,6 +773,7 @@ export class McpServer { if (updates.name !== undefined && updates.name !== name) { delete this._registeredResourceTemplates[name]; if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate; + name = updates.name ?? name; } if (updates.title !== undefined) registeredResourceTemplate.title = updates.title; if (updates.template !== undefined) registeredResourceTemplate.resourceTemplate = updates.template; @@ -759,12 +800,14 @@ export class McpServer { title: string | undefined, description: string | undefined, argsSchema: PromptArgsRawShape | undefined, + _meta: GroupMeta, callback: PromptCallback ): RegisteredPrompt { const registeredPrompt: RegisteredPrompt = { title, description, argsSchema: argsSchema === undefined ? undefined : objectFromShape(argsSchema), + _meta, callback, enabled: true, disable: () => registeredPrompt.update({ enabled: false }), @@ -774,11 +817,13 @@ export class McpServer { if (updates.name !== undefined && updates.name !== name) { delete this._registeredPrompts[name]; if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt; + name = updates.name ?? name; } if (updates.title !== undefined) registeredPrompt.title = updates.title; if (updates.description !== undefined) registeredPrompt.description = updates.description; if (updates.argsSchema !== undefined) registeredPrompt.argsSchema = objectFromShape(updates.argsSchema); if (updates.callback !== undefined) registeredPrompt.callback = updates.callback; + if (updates._meta !== undefined) registeredPrompt._meta = updates._meta; if (updates.enabled !== undefined) registeredPrompt.enabled = updates.enabled; this.sendPromptListChanged(); } @@ -799,6 +844,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: GroupMeta + ): 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 (updates.name !== undefined && updates.name !== name) { + delete this._registeredGroups[name]; + if (updates.name) this._registeredGroups[updates.name] = registeredGroup; + name = updates.name ?? name; + } + 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]; + } + + 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, @@ -807,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 @@ -833,6 +928,7 @@ export class McpServer { } delete this._registeredTools[name]; if (updates.name) this._registeredTools[updates.name] = registeredTool; + name = updates.name ?? name; } if (updates.title !== undefined) registeredTool.title = updates.title; if (updates.description !== undefined) registeredTool.description = updates.description; @@ -847,8 +943,11 @@ export class McpServer { }; this._registeredTools[name] = registeredTool; - this.setToolRequestHandlers(); - this.sendToolListChanged(); + if (this.isConnected()) { + this.sendToolListChanged(); + } else { + this.setToolRequestHandlers(); + } return registeredTool; } @@ -896,6 +995,7 @@ export class McpServer { title?: string; description?: string; argsSchema?: Args; + _meta?: Record; }, cb: PromptCallback ): RegisteredPrompt { @@ -903,13 +1003,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 ); @@ -919,6 +1020,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 @@ -963,6 +1087,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(); + } + } } /** @@ -1232,6 +1365,7 @@ export type RegisteredPrompt = { title?: string; description?: string; argsSchema?: AnyObjectSchema; + _meta?: Record; callback: PromptCallback; enabled: boolean; enable(): void; @@ -1241,12 +1375,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 f705d6b01..941fa36cc 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -687,4 +687,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 14a605c2c..8ef46bcae 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 */