diff --git a/docs/server.md b/docs/server.md index 7dbf642908..000d0e5b2a 100644 --- a/docs/server.md +++ b/docs/server.md @@ -140,6 +140,9 @@ server.registerTool( ); ``` +When a tool declares an `outputSchema`, an error result (`isError: true`) may omit `structuredContent`, but whatever it does send is still validated against that schema by SDK clients. Report failures through `content` or by throwing, and keep error payloads of a different shape +out of `structuredContent`, or the client rejects the response with `-32602` instead of handing your error to the caller. + This snippet is illustrative only; for runnable servers that expose tools, see: - [`simpleStreamableHttp.ts`](../src/examples/server/simpleStreamableHttp.ts) diff --git a/src/client/index.ts b/src/client/index.ts index ae0e698ec3..c7cea10b7d 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -737,7 +737,9 @@ export class Client< ); } - // Only validate structured content if present (not when there's an error) + // Validate structured content whenever it is present, including on error + // results: an error result may omit structuredContent, but anything it does + // send is held to the tool's output schema. if (result.structuredContent) { try { // Validate the structured content against the schema diff --git a/test/client/index.test.ts b/test/client/index.test.ts index f5c6a348d1..05c11d533f 100644 --- a/test/client/index.test.ts +++ b/test/client/index.test.ts @@ -2333,6 +2333,220 @@ describe('outputSchema validation', () => { /Structured content does not match the tool's output schema/ ); }); + /*** + * Test: Error Result Without structuredContent Is Accepted + * + * A tool that declares an outputSchema is still allowed to fail without structured + * output: the "MUST return structuredContent" guard is skipped for error results. + */ + test('should not require structuredContent on an error result', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + server.setRequestHandler(InitializeRequestSchema, async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'failing-tool', + description: 'A tool that fails', + inputSchema: { + type: 'object', + properties: {} + }, + outputSchema: { + type: 'object', + properties: { + score: { type: 'number' } + }, + required: ['score'], + additionalProperties: false + } + } + ] + })); + + server.setRequestHandler(CallToolRequestSchema, async () => ({ + isError: true, + content: [{ type: 'text', text: 'authentication required' }] + })); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // List tools to cache the schemas + await client.listTools(); + + const result = await client.callTool({ name: 'failing-tool' }); + expect(result.isError).toBe(true); + expect(result.structuredContent).toBeUndefined(); + }); + + /*** + * Test: Error Result With structuredContent Is Still Validated + * + * Present-but-invalid structured content is rejected even when the tool reports + * isError, so a server must not put an error payload of its own shape in + * structuredContent. See #2748 - the server half of the SDK skips output + * validation on error results, so this combination is reachable from SDK code. + */ + test('should validate structuredContent on an error result', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + server.setRequestHandler(InitializeRequestSchema, async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'failing-tool', + description: 'A tool that fails', + inputSchema: { + type: 'object', + properties: {} + }, + outputSchema: { + type: 'object', + properties: { + score: { type: 'number' } + }, + required: ['score'], + additionalProperties: false + } + } + ] + })); + + server.setRequestHandler(CallToolRequestSchema, async () => ({ + isError: true, + content: [{ type: 'text', text: '{"code":"AUTH_REQUIRED"}' }], + structuredContent: { code: 'AUTH_REQUIRED' } + })); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // List tools to cache the schemas + await client.listTools(); + + await expect(client.callTool({ name: 'failing-tool' })).rejects.toThrow( + /Structured content does not match the tool's output schema/ + ); + }); + + /*** + * Test: Validation Only Happens Once a Validator Has Been Cached + * + * Validators come from tools/list, so a client that never lists tools sees no + * validation at all. This is what makes the previous case look intermittent: + * the same call throws or succeeds depending on an earlier tools/list. + */ + test('should not validate structuredContent when tools have never been listed', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + server.setRequestHandler(InitializeRequestSchema, async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'failing-tool', + description: 'A tool that fails', + inputSchema: { + type: 'object', + properties: {} + }, + outputSchema: { + type: 'object', + properties: { + score: { type: 'number' } + }, + required: ['score'], + additionalProperties: false + } + } + ] + })); + + server.setRequestHandler(CallToolRequestSchema, async () => ({ + isError: true, + content: [{ type: 'text', text: '{"code":"AUTH_REQUIRED"}' }], + structuredContent: { code: 'AUTH_REQUIRED' } + })); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Deliberately no listTools() here, so no validator is cached + const result = await client.callTool({ name: 'failing-tool' }); + expect(result.structuredContent).toEqual({ code: 'AUTH_REQUIRED' }); + }); }); describe('Task-based execution', () => {