diff --git a/.changeset/respect-capability-negotiation.md b/.changeset/respect-capability-negotiation.md new file mode 100644 index 000000000..a40ac4e7a --- /dev/null +++ b/.changeset/respect-capability-negotiation.md @@ -0,0 +1,13 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Respect capability negotiation in list methods by returning empty lists when server lacks capability + +The Client now returns empty lists instead of sending requests to servers that don't advertise the corresponding capability: +- `listPrompts()` returns `{ prompts: [] }` if server lacks prompts capability +- `listResources()` returns `{ resources: [] }` if server lacks resources capability +- `listResourceTemplates()` returns `{ resourceTemplates: [] }` if server lacks resources capability +- `listTools()` returns `{ tools: [] }` if server lacks tools capability + +This respects the MCP spec requirement that "Both parties SHOULD respect capability negotiation" and avoids unnecessary server warnings and traffic. The existing `enforceStrictCapabilities` option continues to throw errors when set to `true`. diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 2d55a9a01..754d77277 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -253,6 +253,7 @@ export class Client< private _experimental?: { tasks: ExperimentalClientTasks }; private _listChangedDebounceTimers: Map> = new Map(); private _pendingListChangedConfig?: ListChangedHandlers; + private _enforceStrictCapabilities: boolean; /** * Initializes this client with the given name and version information. @@ -264,6 +265,7 @@ export class Client< super(options); this._capabilities = options?.capabilities ?? {}; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new AjvJsonSchemaValidator(); + this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false; // Store list changed config for setup after connection (when we know server capabilities) if (options?.listChanged) { @@ -728,14 +730,31 @@ export class Client< } async listPrompts(params?: ListPromptsRequest['params'], options?: RequestOptions) { + if (!this._serverCapabilities?.prompts && !this._enforceStrictCapabilities) { + // Respect capability negotiation: server does not support prompts + console.debug('Client.listPrompts() called but server does not advertise prompts capability - returning empty list'); + return { prompts: [] }; + } return this.request({ method: 'prompts/list', params }, ListPromptsResultSchema, options); } async listResources(params?: ListResourcesRequest['params'], options?: RequestOptions) { + if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) { + // Respect capability negotiation: server does not support resources + console.debug('Client.listResources() called but server does not advertise resources capability - returning empty list'); + return { resources: [] }; + } return this.request({ method: 'resources/list', params }, ListResourcesResultSchema, options); } async listResourceTemplates(params?: ListResourceTemplatesRequest['params'], options?: RequestOptions) { + if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) { + // Respect capability negotiation: server does not support resources + console.debug( + 'Client.listResourceTemplates() called but server does not advertise resources capability - returning empty list' + ); + return { resourceTemplates: [] }; + } return this.request({ method: 'resources/templates/list', params }, ListResourceTemplatesResultSchema, options); } @@ -860,6 +879,11 @@ export class Client< } async listTools(params?: ListToolsRequest['params'], options?: RequestOptions) { + if (!this._serverCapabilities?.tools && !this._enforceStrictCapabilities) { + // Respect capability negotiation: server does not support tools + console.debug('Client.listTools() called but server does not advertise tools capability - returning empty list'); + return { tools: [] }; + } const result = await this.request({ method: 'tools/list', params }, ListToolsResultSchema, options); // Cache the tools and their output schemas for future validation diff --git a/test/integration/test/client/client.test.ts b/test/integration/test/client/client.test.ts index 14a605c2c..ed1ea7d67 100644 --- a/test/integration/test/client/client.test.ts +++ b/test/integration/test/client/client.test.ts @@ -595,6 +595,79 @@ test('should respect server capabilities', async () => { ).rejects.toThrow('Server does not support completions'); }); +/*** + * Test: Return empty lists for missing capabilities (default behavior) + * When enforceStrictCapabilities is not set (default), list methods should + * return empty lists instead of sending requests to servers that don't + * advertise those capabilities. + */ +test('should return empty lists for missing capabilities by default', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + // Server only supports tools - no prompts or resources + tools: {} + } + } + ); + + server.setRequestHandler(InitializeRequestSchema, _request => ({ + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { + tools: {} + }, + serverInfo: { + name: 'test', + version: '1.0' + } + })); + + server.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: [{ name: 'test-tool', inputSchema: { type: 'object' } }] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + // Client with default settings (enforceStrictCapabilities not set) + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: {} + } + ); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Server only supports tools + expect(client.getServerCapabilities()).toEqual({ + tools: {} + }); + + // listTools should work and return actual tools + const toolsResult = await client.listTools(); + expect(toolsResult.tools).toHaveLength(1); + expect(toolsResult.tools[0].name).toBe('test-tool'); + + // listPrompts should return empty list without sending request + const promptsResult = await client.listPrompts(); + expect(promptsResult.prompts).toEqual([]); + + // listResources should return empty list without sending request + const resourcesResult = await client.listResources(); + expect(resourcesResult.resources).toEqual([]); + + // listResourceTemplates should return empty list without sending request + const templatesResult = await client.listResourceTemplates(); + expect(templatesResult.resourceTemplates).toEqual([]); +}); + /*** * Test: Respect Client Notification Capabilities */ @@ -1885,7 +1958,7 @@ describe('outputSchema validation', () => { // Set up server handlers server.setRequestHandler(InitializeRequestSchema, async request => ({ protocolVersion: request.params.protocolVersion, - capabilities: {}, + capabilities: { tools: {} }, serverInfo: { name: 'test-server', version: '1.0.0' @@ -1977,7 +2050,7 @@ describe('outputSchema validation', () => { // Set up server handlers server.setRequestHandler(InitializeRequestSchema, async request => ({ protocolVersion: request.params.protocolVersion, - capabilities: {}, + capabilities: { tools: {} }, serverInfo: { name: 'test-server', version: '1.0.0' @@ -2270,7 +2343,7 @@ describe('outputSchema validation', () => { // Set up server handlers server.setRequestHandler(InitializeRequestSchema, async request => ({ protocolVersion: request.params.protocolVersion, - capabilities: {}, + capabilities: { tools: {} }, serverInfo: { name: 'test-server', version: '1.0.0'