diff --git a/.changeset/fix-call-tool-schema-generation.md b/.changeset/fix-call-tool-schema-generation.md new file mode 100644 index 0000000000..bc951b650a --- /dev/null +++ b/.changeset/fix-call-tool-schema-generation.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +Validate each `callTool()` result against the output schema cached when the call begins, so a concurrent tool-list refresh cannot apply a newer schema to an in-flight result. diff --git a/src/client/index.ts b/src/client/index.ts index ae0e698ec3..818505ddb8 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -724,10 +724,11 @@ export class Client< ); } + // Capture the validator before dispatch so a concurrent listTools() refresh cannot change + // the schema generation used for this in-flight call. + const validator = this.getToolOutputValidator(params.name); const result = await this.request({ method: 'tools/call', params }, resultSchema, options); - // Check if the tool has an outputSchema - const validator = this.getToolOutputValidator(params.name); if (validator) { // If tool has outputSchema, it MUST return structuredContent (unless it's an error) if (!result.structuredContent && !result.isError) { diff --git a/test/client/index.test.ts b/test/client/index.test.ts index f5c6a348d1..660615987c 100644 --- a/test/client/index.test.ts +++ b/test/client/index.test.ts @@ -1779,6 +1779,77 @@ test('should handle partial listChanged capability support', async () => { }); describe('outputSchema validation', () => { + test('should validate an in-flight call against the output schema active when it started', async () => { + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: {} + } + ); + + let catalogGeneration = 'old'; + let callCount = 0; + let releaseFirstCall!: () => void; + const firstCallMayReturn = new Promise(resolve => { + releaseFirstCall = resolve; + }); + + vi.spyOn(client, 'request').mockImplementation(async request => { + if (request.method === 'tools/list') { + return { + tools: [ + { + name: 'versioned-tool', + inputSchema: { + type: 'object', + properties: {} + }, + outputSchema: { + type: 'object', + properties: { + generation: { const: catalogGeneration } + }, + required: ['generation'], + additionalProperties: false + } + } + ] + }; + } + + if (request.method === 'tools/call') { + callCount += 1; + const callGeneration = catalogGeneration; + if (callCount === 1) { + await firstCallMayReturn; + } + return { + content: [], + structuredContent: { generation: callGeneration } + }; + } + + throw new Error(`Unexpected request: ${request.method}`); + }); + + await client.listTools(); + const firstCall = client.callTool({ name: 'versioned-tool' }); + + catalogGeneration = 'new'; + await client.listTools(); + releaseFirstCall(); + + await expect(firstCall).resolves.toMatchObject({ + structuredContent: { generation: 'old' } + }); + await expect(client.callTool({ name: 'versioned-tool' })).resolves.toMatchObject({ + structuredContent: { generation: 'new' } + }); + }); + /*** * Test: Validate structuredContent Against outputSchema */