From 75fa9d640db934aa609660939990a17155bd0fe1 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Mon, 19 Jan 2026 23:09:21 -0800 Subject: [PATCH 1/6] fix(ai-sdk): Fix MCP tool inputSchema after activity serialization (#1889) When MCP tools are retrieved via the listTools activity, the inputSchema is a wrapper object with a getter. After JSON serialization through Temporal's activity boundary, it becomes { jsonSchema: {...} }. The previous code wrapped this again with jsonSchema(), creating double-nesting: { jsonSchema: { jsonSchema: {...} } } This caused: - OpenAI API errors: "Invalid schema: type 'None'" (type was undefined) - Missing properties in tool definitions - Missing property descriptions Changes: - activities.ts: Update ListToolResult type to reflect serialized form - mcp.ts: Extract .jsonSchema before wrapping Co-Authored-By: Claude Opus 4.5 --- packages/ai-sdk/src/activities.ts | 11 +- packages/ai-sdk/src/mcp.ts | 4 +- packages/test/src/test-ai-sdk.ts | 196 ++++++++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 4 deletions(-) diff --git a/packages/ai-sdk/src/activities.ts b/packages/ai-sdk/src/activities.ts index 397b76922..f65d3b495 100644 --- a/packages/ai-sdk/src/activities.ts +++ b/packages/ai-sdk/src/activities.ts @@ -5,8 +5,9 @@ import type { SharedV3ProviderOptions, SharedV3Headers, ProviderV3, + JSONSchema7, } from '@ai-sdk/provider'; -import type { FlexibleSchema, ToolExecutionOptions } from 'ai'; +import type { ToolExecutionOptions } from 'ai'; import { ApplicationFailure } from '@temporalio/common'; import type { McpClientFactories, McpClientFactory } from './mcp'; @@ -42,9 +43,15 @@ export interface InvokeEmbeddingModelArgs { */ export type InvokeEmbeddingModelResult = EmbeddingModelV3Result; +/** + * Result from listing MCP tools, returned by the listTools activity. + * Note: inputSchema is the serialized form of the AI SDK's schema wrapper. + * After JSON serialization through Temporal's activity boundary, the schema + * wrapper becomes { jsonSchema: JSONSchema7 }. + */ export interface ListToolResult { description?: string; - inputSchema: FlexibleSchema; + inputSchema: { jsonSchema: JSONSchema7 }; } export interface ListToolArgs { diff --git a/packages/ai-sdk/src/mcp.ts b/packages/ai-sdk/src/mcp.ts index 988bb6246..9c4d883b1 100644 --- a/packages/ai-sdk/src/mcp.ts +++ b/packages/ai-sdk/src/mcp.ts @@ -1,5 +1,4 @@ import { jsonSchema, type ToolSet } from 'ai'; -import type { JSONSchema7 } from '@ai-sdk/provider'; import type { experimental_MCPClient as MCPClient } from '@ai-sdk/mcp'; import * as workflow from '@temporalio/workflow'; import type { ActivityOptions } from '@temporalio/workflow'; @@ -52,7 +51,8 @@ export class TemporalMCPClient { const callActivity = activities[this.options.name + '-callTool']!; return await callActivity({ name: toolName, input, options, clientArgs: this.options.clientArgs }); }, - inputSchema: jsonSchema(toolResult.inputSchema as JSONSchema7), + // toolResult.inputSchema is the serialized form { jsonSchema: JSONSchema7 } + inputSchema: jsonSchema(toolResult.inputSchema.jsonSchema), type: 'dynamic', }, ]) diff --git a/packages/test/src/test-ai-sdk.ts b/packages/test/src/test-ai-sdk.ts index 50cc726c8..293d6203c 100644 --- a/packages/test/src/test-ai-sdk.ts +++ b/packages/test/src/test-ai-sdk.ts @@ -544,6 +544,202 @@ test('callToolActivity awaits tool.execute before closing MCP client', async (t) ); }); +test('MCP tool inputSchema survives activity serialization', async (t) => { + // This test verifies that tool inputSchema is correctly reconstructed after + // being serialized through Temporal's activity boundary. + // + // The bug: When the listTools activity returns, v.inputSchema is a schema wrapper + // object with a getter. After JSON serialization (Temporal activity return), it becomes + // { jsonSchema: {...} }. The workflow code then wraps this again with jsonSchema(), + // creating { jsonSchema: { jsonSchema: {...} } } - double nesting that breaks the schema. + // + // This caused: + // - "Invalid schema: type 'None'" errors from OpenAI (type was undefined) + // - Missing properties in tool definitions (#1889) + // - Missing property descriptions (#1889) + + const { jsonSchema } = await import('ai'); + + // Simulate what the AI SDK's MCP client returns for a tool's inputSchema + // Include property descriptions to test #1889 fix + const originalSchema = jsonSchema({ + type: 'object', + properties: { + regionId: { + type: 'string', + description: 'The region ID to query', + }, + limit: { + type: 'number', + description: 'Maximum results to return', + }, + }, + required: ['regionId'], + additionalProperties: false, + }); + + // Simulate what the listTools activity returns (extracts inputSchema from tool) + const activityResult = { + testTool: { + description: 'A test tool', + inputSchema: originalSchema, + }, + }; + + // Simulate JSON serialization that happens when activity returns to workflow + // This is what Temporal does when passing data from activity to workflow + const serialized = JSON.stringify(activityResult); + const deserialized = JSON.parse(serialized); + + // This is what the workflow receives - inputSchema is now { jsonSchema: {...} } + const toolResult = deserialized.testTool; + + // Verify the serialized structure + t.truthy(toolResult.inputSchema.jsonSchema, 'After serialization, inputSchema has jsonSchema property'); + t.is(toolResult.inputSchema.type, undefined, 'After serialization, inputSchema.type is undefined (nested)'); + + // The fix: access toolResult.inputSchema.jsonSchema before wrapping + const fixedSchema = jsonSchema(toolResult.inputSchema.jsonSchema); + + // Verify schema type is preserved + t.is(fixedSchema.jsonSchema.type, 'object', 'Schema type should be "object"'); + + // Verify properties are preserved (#1889) + t.truthy(fixedSchema.jsonSchema.properties, 'Schema should have properties'); + t.truthy(fixedSchema.jsonSchema.properties.regionId, 'Schema should have regionId property'); + t.truthy(fixedSchema.jsonSchema.properties.limit, 'Schema should have limit property'); + + // Verify property descriptions are preserved (#1889) + t.is( + fixedSchema.jsonSchema.properties.regionId.description, + 'The region ID to query', + 'Property description should be preserved' + ); + t.is( + fixedSchema.jsonSchema.properties.limit.description, + 'Maximum results to return', + 'Property description should be preserved' + ); + + // Verify required fields are preserved + t.deepEqual(fixedSchema.jsonSchema.required, ['regionId'], 'Required fields should be preserved'); + + // Verify additionalProperties is preserved + t.is(fixedSchema.jsonSchema.additionalProperties, false, 'additionalProperties should be preserved'); + + // Also verify what the buggy code produces (to document the bug) + const buggySchema = jsonSchema(toolResult.inputSchema); + t.is(buggySchema.jsonSchema.type, undefined, 'Buggy: type is undefined (double-wrapped)'); + t.is(buggySchema.jsonSchema.properties, undefined, 'Buggy: properties is undefined (double-wrapped)'); + t.truthy(buggySchema.jsonSchema.jsonSchema, 'Buggy: has nested jsonSchema showing double-wrapping'); +}); + +test('MCP listTools activity preserves tool and parameter metadata (#1889)', async (t) => { + // This test verifies the full flow from MCP client through activity serialization, + // ensuring tool descriptions and parameter metadata are preserved. + // Regression test for https://github.com/temporalio/sdk-typescript/issues/1889 + + const { jsonSchema } = await import('ai'); + + // Simulate a real MCP tool with full metadata + const mockMcpToolSchema = jsonSchema({ + type: 'object', + properties: { + regionId: { + type: 'string', + description: 'The AWS region identifier (e.g., us-east-1)', + }, + serviceType: { + type: 'string', + enum: ['compute', 'storage', 'database'], + description: 'Type of service to filter by', + }, + maxResults: { + type: 'integer', + description: 'Maximum number of services to return', + default: 10, + }, + }, + required: ['regionId'], + additionalProperties: false, + }); + + // Create mock MCP client similar to what @ai-sdk/mcp returns + const mockMcpClient = { + async tools() { + return { + 'list-services': { + description: 'Lists all available services in a specified AWS region with optional filtering', + inputSchema: mockMcpToolSchema, + execute: async () => ({ services: [] }), + }, + }; + }, + async close() {}, + }; + + // Simulate what listToolsActivity does (from activities.ts) + const mcpTools = await mockMcpClient.tools(); + const activityResult = Object.fromEntries( + Object.entries(mcpTools).map(([k, v]) => [ + k, + { + description: v.description, + inputSchema: v.inputSchema, + }, + ]) + ); + + // Simulate Temporal activity serialization round-trip + const serialized = JSON.stringify(activityResult); + const workflowReceives = JSON.parse(serialized); + + // Verify tool description is preserved + t.is( + workflowReceives['list-services'].description, + 'Lists all available services in a specified AWS region with optional filtering', + 'Tool description should survive serialization' + ); + + // Simulate what mcp.ts does with the fix applied + const toolResult = workflowReceives['list-services']; + const reconstructedSchema = jsonSchema(toolResult.inputSchema.jsonSchema); + + // Verify all schema metadata is preserved + const schema = reconstructedSchema.jsonSchema; + + t.is(schema.type, 'object', 'Schema type preserved'); + t.deepEqual(schema.required, ['regionId'], 'Required fields preserved'); + t.is(schema.additionalProperties, false, 'additionalProperties preserved'); + + // Verify all properties and their metadata + t.truthy(schema.properties.regionId, 'regionId property exists'); + t.is(schema.properties.regionId.type, 'string', 'regionId type preserved'); + t.is( + schema.properties.regionId.description, + 'The AWS region identifier (e.g., us-east-1)', + 'regionId description preserved' + ); + + t.truthy(schema.properties.serviceType, 'serviceType property exists'); + t.is(schema.properties.serviceType.type, 'string', 'serviceType type preserved'); + t.deepEqual(schema.properties.serviceType.enum, ['compute', 'storage', 'database'], 'serviceType enum preserved'); + t.is( + schema.properties.serviceType.description, + 'Type of service to filter by', + 'serviceType description preserved' + ); + + t.truthy(schema.properties.maxResults, 'maxResults property exists'); + t.is(schema.properties.maxResults.type, 'integer', 'maxResults type preserved'); + t.is(schema.properties.maxResults.default, 10, 'maxResults default preserved'); + t.is( + schema.properties.maxResults.description, + 'Maximum number of services to return', + 'maxResults description preserved' + ); +}); + // Currently fails in CI due to invalid server response but passes locally test.skip('MCP Use', async (t) => { if (remoteTests) { From 9a22eaa4c4ad7a76b68c24e92e038efaf59bc425 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Mon, 19 Jan 2026 23:37:00 -0800 Subject: [PATCH 2/6] fix: Add type casts for JSONSchema7 in tests The jsonSchema().jsonSchema property can be JSONSchema7 | PromiseLike. Since tests use synchronous values, add explicit casts to satisfy TypeScript. Co-Authored-By: Claude Opus 4.5 --- packages/test/src/test-ai-sdk.ts | 83 +++++++++++++++----------------- 1 file changed, 38 insertions(+), 45 deletions(-) diff --git a/packages/test/src/test-ai-sdk.ts b/packages/test/src/test-ai-sdk.ts index 293d6203c..5f26cd8c3 100644 --- a/packages/test/src/test-ai-sdk.ts +++ b/packages/test/src/test-ai-sdk.ts @@ -6,6 +6,7 @@ import type { EmbeddingModelV3CallOptions, EmbeddingModelV3Result, ImageModelV3, + JSONSchema7, LanguageModelV3, LanguageModelV3CallOptions, LanguageModelV3Content, @@ -600,38 +601,36 @@ test('MCP tool inputSchema survives activity serialization', async (t) => { // The fix: access toolResult.inputSchema.jsonSchema before wrapping const fixedSchema = jsonSchema(toolResult.inputSchema.jsonSchema); + // Cast to JSONSchema7 since we know it's synchronous in this test + const schema = fixedSchema.jsonSchema as JSONSchema7; // Verify schema type is preserved - t.is(fixedSchema.jsonSchema.type, 'object', 'Schema type should be "object"'); + t.is(schema.type, 'object', 'Schema type should be "object"'); // Verify properties are preserved (#1889) - t.truthy(fixedSchema.jsonSchema.properties, 'Schema should have properties'); - t.truthy(fixedSchema.jsonSchema.properties.regionId, 'Schema should have regionId property'); - t.truthy(fixedSchema.jsonSchema.properties.limit, 'Schema should have limit property'); + t.truthy(schema.properties, 'Schema should have properties'); + t.truthy(schema.properties!.regionId, 'Schema should have regionId property'); + t.truthy(schema.properties!.limit, 'Schema should have limit property'); // Verify property descriptions are preserved (#1889) - t.is( - fixedSchema.jsonSchema.properties.regionId.description, - 'The region ID to query', - 'Property description should be preserved' - ); - t.is( - fixedSchema.jsonSchema.properties.limit.description, - 'Maximum results to return', - 'Property description should be preserved' - ); + const regionIdProp = schema.properties!.regionId as JSONSchema7; + const limitProp = schema.properties!.limit as JSONSchema7; + t.is(regionIdProp.description, 'The region ID to query', 'Property description should be preserved'); + t.is(limitProp.description, 'Maximum results to return', 'Property description should be preserved'); // Verify required fields are preserved - t.deepEqual(fixedSchema.jsonSchema.required, ['regionId'], 'Required fields should be preserved'); + t.deepEqual(schema.required, ['regionId'], 'Required fields should be preserved'); // Verify additionalProperties is preserved - t.is(fixedSchema.jsonSchema.additionalProperties, false, 'additionalProperties should be preserved'); + t.is(schema.additionalProperties, false, 'additionalProperties should be preserved'); // Also verify what the buggy code produces (to document the bug) const buggySchema = jsonSchema(toolResult.inputSchema); - t.is(buggySchema.jsonSchema.type, undefined, 'Buggy: type is undefined (double-wrapped)'); - t.is(buggySchema.jsonSchema.properties, undefined, 'Buggy: properties is undefined (double-wrapped)'); - t.truthy(buggySchema.jsonSchema.jsonSchema, 'Buggy: has nested jsonSchema showing double-wrapping'); + const buggyInner = buggySchema.jsonSchema as JSONSchema7; + t.is(buggyInner.type, undefined, 'Buggy: type is undefined (double-wrapped)'); + t.is(buggyInner.properties, undefined, 'Buggy: properties is undefined (double-wrapped)'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + t.truthy((buggyInner as any).jsonSchema, 'Buggy: has nested jsonSchema showing double-wrapping'); }); test('MCP listTools activity preserves tool and parameter metadata (#1889)', async (t) => { @@ -706,38 +705,32 @@ test('MCP listTools activity preserves tool and parameter metadata (#1889)', asy const reconstructedSchema = jsonSchema(toolResult.inputSchema.jsonSchema); // Verify all schema metadata is preserved - const schema = reconstructedSchema.jsonSchema; + // Cast to JSONSchema7 since we know it's synchronous in this test + const schema = reconstructedSchema.jsonSchema as JSONSchema7; t.is(schema.type, 'object', 'Schema type preserved'); t.deepEqual(schema.required, ['regionId'], 'Required fields preserved'); t.is(schema.additionalProperties, false, 'additionalProperties preserved'); // Verify all properties and their metadata - t.truthy(schema.properties.regionId, 'regionId property exists'); - t.is(schema.properties.regionId.type, 'string', 'regionId type preserved'); - t.is( - schema.properties.regionId.description, - 'The AWS region identifier (e.g., us-east-1)', - 'regionId description preserved' - ); - - t.truthy(schema.properties.serviceType, 'serviceType property exists'); - t.is(schema.properties.serviceType.type, 'string', 'serviceType type preserved'); - t.deepEqual(schema.properties.serviceType.enum, ['compute', 'storage', 'database'], 'serviceType enum preserved'); - t.is( - schema.properties.serviceType.description, - 'Type of service to filter by', - 'serviceType description preserved' - ); - - t.truthy(schema.properties.maxResults, 'maxResults property exists'); - t.is(schema.properties.maxResults.type, 'integer', 'maxResults type preserved'); - t.is(schema.properties.maxResults.default, 10, 'maxResults default preserved'); - t.is( - schema.properties.maxResults.description, - 'Maximum number of services to return', - 'maxResults description preserved' - ); + const props = schema.properties!; + const regionIdProp = props.regionId as JSONSchema7; + const serviceTypeProp = props.serviceType as JSONSchema7; + const maxResultsProp = props.maxResults as JSONSchema7; + + t.truthy(regionIdProp, 'regionId property exists'); + t.is(regionIdProp.type, 'string', 'regionId type preserved'); + t.is(regionIdProp.description, 'The AWS region identifier (e.g., us-east-1)', 'regionId description preserved'); + + t.truthy(serviceTypeProp, 'serviceType property exists'); + t.is(serviceTypeProp.type, 'string', 'serviceType type preserved'); + t.deepEqual(serviceTypeProp.enum, ['compute', 'storage', 'database'], 'serviceType enum preserved'); + t.is(serviceTypeProp.description, 'Type of service to filter by', 'serviceType description preserved'); + + t.truthy(maxResultsProp, 'maxResults property exists'); + t.is(maxResultsProp.type, 'integer', 'maxResults type preserved'); + t.is(maxResultsProp.default, 10, 'maxResults default preserved'); + t.is(maxResultsProp.description, 'Maximum number of services to return', 'maxResults description preserved'); }); // Currently fails in CI due to invalid server response but passes locally From 4abe56600f200c2a7023df08b4ea81f955cbeadd Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Mon, 19 Jan 2026 23:39:45 -0800 Subject: [PATCH 3/6] Fix type cast for inputSchema in listToolsActivity The inputSchema from MCP client is FlexibleSchema (pre-serialization), but the ListToolResult type describes the post-serialization form. Add a cast to bridge the type gap since Temporal serialization handles the conversion. Co-Authored-By: Claude Opus 4.5 --- packages/ai-sdk/src/activities.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ai-sdk/src/activities.ts b/packages/ai-sdk/src/activities.ts index f65d3b495..7a34820d7 100644 --- a/packages/ai-sdk/src/activities.ts +++ b/packages/ai-sdk/src/activities.ts @@ -108,12 +108,14 @@ function activitiesForName(name: string, mcpClientFactory: McpClientFactory): ob try { const tools = await mcpClient.tools(); + // Cast is necessary because v.inputSchema is FlexibleSchema (with getters), + // but after Temporal JSON serialization it becomes { jsonSchema: JSONSchema7 } return Object.fromEntries( Object.entries(tools).map(([k, v]) => [ k, { description: v.description, - inputSchema: v.inputSchema, + inputSchema: v.inputSchema as ListToolResult['inputSchema'], }, ]) ); From b8252a60febfb1585221e85857c2fdf558c8b656 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Tue, 20 Jan 2026 00:06:54 -0800 Subject: [PATCH 4/6] Clarify comment on inputSchema cast Co-Authored-By: Claude Opus 4.5 --- packages/ai-sdk/src/activities.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ai-sdk/src/activities.ts b/packages/ai-sdk/src/activities.ts index 7a34820d7..20c2c0f27 100644 --- a/packages/ai-sdk/src/activities.ts +++ b/packages/ai-sdk/src/activities.ts @@ -108,8 +108,8 @@ function activitiesForName(name: string, mcpClientFactory: McpClientFactory): ob try { const tools = await mcpClient.tools(); - // Cast is necessary because v.inputSchema is FlexibleSchema (with getters), - // but after Temporal JSON serialization it becomes { jsonSchema: JSONSchema7 } + // The activity returns FlexibleSchema objects, but ListToolResult describes the + // post-serialization form that workflows receive. The cast bridges this gap. return Object.fromEntries( Object.entries(tools).map(([k, v]) => [ k, From d1f3d344bafdebec337a9bc64656eb4b52a9cb90 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Tue, 20 Jan 2026 08:16:41 -0800 Subject: [PATCH 5/6] Remove redundant MCP schema serialization tests These tests simulated JSON serialization behavior but didn't actually exercise the mcp.ts code path. The fix is correct by inspection. Co-Authored-By: Claude Opus 4.5 --- packages/test/src/test-ai-sdk.ts | 189 ------------------------------- 1 file changed, 189 deletions(-) diff --git a/packages/test/src/test-ai-sdk.ts b/packages/test/src/test-ai-sdk.ts index 5f26cd8c3..50cc726c8 100644 --- a/packages/test/src/test-ai-sdk.ts +++ b/packages/test/src/test-ai-sdk.ts @@ -6,7 +6,6 @@ import type { EmbeddingModelV3CallOptions, EmbeddingModelV3Result, ImageModelV3, - JSONSchema7, LanguageModelV3, LanguageModelV3CallOptions, LanguageModelV3Content, @@ -545,194 +544,6 @@ test('callToolActivity awaits tool.execute before closing MCP client', async (t) ); }); -test('MCP tool inputSchema survives activity serialization', async (t) => { - // This test verifies that tool inputSchema is correctly reconstructed after - // being serialized through Temporal's activity boundary. - // - // The bug: When the listTools activity returns, v.inputSchema is a schema wrapper - // object with a getter. After JSON serialization (Temporal activity return), it becomes - // { jsonSchema: {...} }. The workflow code then wraps this again with jsonSchema(), - // creating { jsonSchema: { jsonSchema: {...} } } - double nesting that breaks the schema. - // - // This caused: - // - "Invalid schema: type 'None'" errors from OpenAI (type was undefined) - // - Missing properties in tool definitions (#1889) - // - Missing property descriptions (#1889) - - const { jsonSchema } = await import('ai'); - - // Simulate what the AI SDK's MCP client returns for a tool's inputSchema - // Include property descriptions to test #1889 fix - const originalSchema = jsonSchema({ - type: 'object', - properties: { - regionId: { - type: 'string', - description: 'The region ID to query', - }, - limit: { - type: 'number', - description: 'Maximum results to return', - }, - }, - required: ['regionId'], - additionalProperties: false, - }); - - // Simulate what the listTools activity returns (extracts inputSchema from tool) - const activityResult = { - testTool: { - description: 'A test tool', - inputSchema: originalSchema, - }, - }; - - // Simulate JSON serialization that happens when activity returns to workflow - // This is what Temporal does when passing data from activity to workflow - const serialized = JSON.stringify(activityResult); - const deserialized = JSON.parse(serialized); - - // This is what the workflow receives - inputSchema is now { jsonSchema: {...} } - const toolResult = deserialized.testTool; - - // Verify the serialized structure - t.truthy(toolResult.inputSchema.jsonSchema, 'After serialization, inputSchema has jsonSchema property'); - t.is(toolResult.inputSchema.type, undefined, 'After serialization, inputSchema.type is undefined (nested)'); - - // The fix: access toolResult.inputSchema.jsonSchema before wrapping - const fixedSchema = jsonSchema(toolResult.inputSchema.jsonSchema); - // Cast to JSONSchema7 since we know it's synchronous in this test - const schema = fixedSchema.jsonSchema as JSONSchema7; - - // Verify schema type is preserved - t.is(schema.type, 'object', 'Schema type should be "object"'); - - // Verify properties are preserved (#1889) - t.truthy(schema.properties, 'Schema should have properties'); - t.truthy(schema.properties!.regionId, 'Schema should have regionId property'); - t.truthy(schema.properties!.limit, 'Schema should have limit property'); - - // Verify property descriptions are preserved (#1889) - const regionIdProp = schema.properties!.regionId as JSONSchema7; - const limitProp = schema.properties!.limit as JSONSchema7; - t.is(regionIdProp.description, 'The region ID to query', 'Property description should be preserved'); - t.is(limitProp.description, 'Maximum results to return', 'Property description should be preserved'); - - // Verify required fields are preserved - t.deepEqual(schema.required, ['regionId'], 'Required fields should be preserved'); - - // Verify additionalProperties is preserved - t.is(schema.additionalProperties, false, 'additionalProperties should be preserved'); - - // Also verify what the buggy code produces (to document the bug) - const buggySchema = jsonSchema(toolResult.inputSchema); - const buggyInner = buggySchema.jsonSchema as JSONSchema7; - t.is(buggyInner.type, undefined, 'Buggy: type is undefined (double-wrapped)'); - t.is(buggyInner.properties, undefined, 'Buggy: properties is undefined (double-wrapped)'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - t.truthy((buggyInner as any).jsonSchema, 'Buggy: has nested jsonSchema showing double-wrapping'); -}); - -test('MCP listTools activity preserves tool and parameter metadata (#1889)', async (t) => { - // This test verifies the full flow from MCP client through activity serialization, - // ensuring tool descriptions and parameter metadata are preserved. - // Regression test for https://github.com/temporalio/sdk-typescript/issues/1889 - - const { jsonSchema } = await import('ai'); - - // Simulate a real MCP tool with full metadata - const mockMcpToolSchema = jsonSchema({ - type: 'object', - properties: { - regionId: { - type: 'string', - description: 'The AWS region identifier (e.g., us-east-1)', - }, - serviceType: { - type: 'string', - enum: ['compute', 'storage', 'database'], - description: 'Type of service to filter by', - }, - maxResults: { - type: 'integer', - description: 'Maximum number of services to return', - default: 10, - }, - }, - required: ['regionId'], - additionalProperties: false, - }); - - // Create mock MCP client similar to what @ai-sdk/mcp returns - const mockMcpClient = { - async tools() { - return { - 'list-services': { - description: 'Lists all available services in a specified AWS region with optional filtering', - inputSchema: mockMcpToolSchema, - execute: async () => ({ services: [] }), - }, - }; - }, - async close() {}, - }; - - // Simulate what listToolsActivity does (from activities.ts) - const mcpTools = await mockMcpClient.tools(); - const activityResult = Object.fromEntries( - Object.entries(mcpTools).map(([k, v]) => [ - k, - { - description: v.description, - inputSchema: v.inputSchema, - }, - ]) - ); - - // Simulate Temporal activity serialization round-trip - const serialized = JSON.stringify(activityResult); - const workflowReceives = JSON.parse(serialized); - - // Verify tool description is preserved - t.is( - workflowReceives['list-services'].description, - 'Lists all available services in a specified AWS region with optional filtering', - 'Tool description should survive serialization' - ); - - // Simulate what mcp.ts does with the fix applied - const toolResult = workflowReceives['list-services']; - const reconstructedSchema = jsonSchema(toolResult.inputSchema.jsonSchema); - - // Verify all schema metadata is preserved - // Cast to JSONSchema7 since we know it's synchronous in this test - const schema = reconstructedSchema.jsonSchema as JSONSchema7; - - t.is(schema.type, 'object', 'Schema type preserved'); - t.deepEqual(schema.required, ['regionId'], 'Required fields preserved'); - t.is(schema.additionalProperties, false, 'additionalProperties preserved'); - - // Verify all properties and their metadata - const props = schema.properties!; - const regionIdProp = props.regionId as JSONSchema7; - const serviceTypeProp = props.serviceType as JSONSchema7; - const maxResultsProp = props.maxResults as JSONSchema7; - - t.truthy(regionIdProp, 'regionId property exists'); - t.is(regionIdProp.type, 'string', 'regionId type preserved'); - t.is(regionIdProp.description, 'The AWS region identifier (e.g., us-east-1)', 'regionId description preserved'); - - t.truthy(serviceTypeProp, 'serviceType property exists'); - t.is(serviceTypeProp.type, 'string', 'serviceType type preserved'); - t.deepEqual(serviceTypeProp.enum, ['compute', 'storage', 'database'], 'serviceType enum preserved'); - t.is(serviceTypeProp.description, 'Type of service to filter by', 'serviceType description preserved'); - - t.truthy(maxResultsProp, 'maxResults property exists'); - t.is(maxResultsProp.type, 'integer', 'maxResults type preserved'); - t.is(maxResultsProp.default, 10, 'maxResults default preserved'); - t.is(maxResultsProp.description, 'Maximum number of services to return', 'maxResults description preserved'); -}); - // Currently fails in CI due to invalid server response but passes locally test.skip('MCP Use', async (t) => { if (remoteTests) { From bff941098bd3eb4d9c70fa5159276d17352c0cc4 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Tue, 20 Jan 2026 08:52:48 -0800 Subject: [PATCH 6/6] Add integration test for MCP schema serialization fix This test verifies that MCP tool inputSchema survives Temporal activity serialization correctly. It uses: - Real MCP SDK Server with InMemoryTransport (no external dependencies) - Real AI SDK createMCPClient (catches AI SDK changes) - Workflow that exercises the actual mcp.ts code path The test fails on main (schemaType=undefined) and passes with the fix. Co-Authored-By: Claude Opus 4.5 --- packages/test/src/test-ai-sdk.ts | 63 +++++++++++++++++++++++++++ packages/test/src/workflows/ai-sdk.ts | 25 +++++++++++ 2 files changed, 88 insertions(+) diff --git a/packages/test/src/test-ai-sdk.ts b/packages/test/src/test-ai-sdk.ts index 50cc726c8..04be292b9 100644 --- a/packages/test/src/test-ai-sdk.ts +++ b/packages/test/src/test-ai-sdk.ts @@ -22,6 +22,9 @@ import * as opentelemetry from '@opentelemetry/sdk-node'; import { SEMRESATTRS_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; import { ExportResultCode } from '@opentelemetry/core'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { experimental_createMCPClient as createMCPClient } from '@ai-sdk/mcp'; import { AiSdkPlugin, createActivities } from '@temporalio/ai-sdk'; import { temporal } from '@temporalio/proto'; @@ -39,6 +42,7 @@ import { embeddingWorkflow, generateObjectWorkflow, helloWorldAgent, + mcpSchemaTestWorkflow, mcpWorkflow, middlewareWorkflow, telemetryWorkflow, @@ -544,6 +548,65 @@ test('callToolActivity awaits tool.execute before closing MCP client', async (t) ); }); +test('MCP tool schema survives activity serialization', async (t) => { + const { createWorker, executeWorkflow } = helpers(t); + + // Create in-memory MCP server with test tool + const createTestMcpClient = async () => { + const server = new Server( + { name: 'test-server', version: '1.0.0' }, + { capabilities: { tools: {} } } + ); + + // Register tools/list handler + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'testTool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: { + testParam: { type: 'string', description: 'Test parameter' }, + }, + required: ['testParam'], + }, + }, + ], + })); + + // Register tools/call handler + server.setRequestHandler(CallToolRequestSchema, async () => ({ + content: [{ type: 'text', text: 'ok' }], + })); + + // Create linked in-memory transports + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + + // Return real AI SDK MCP client connected to in-memory server + return createMCPClient({ transport: clientTransport }); + }; + + const worker = await createWorker({ + plugins: [ + new AiSdkPlugin({ + modelProvider: new TestProvider(helloWorkflowGenerator()), + mcpClientFactories: { testServer: createTestMcpClient }, + }), + ], + }); + + await worker.runUntil(async () => { + const result = await executeWorkflow(mcpSchemaTestWorkflow); + + // These would FAIL with the bug (undefined), PASS with fix + t.is(result.schemaType, 'object', 'Schema type should be "object", not undefined'); + t.true(result.hasProperties, 'Schema should have properties'); + t.is(result.propertyDescription, 'Test parameter', 'Property description should be preserved'); + }); +}); + // Currently fails in CI due to invalid server response but passes locally test.skip('MCP Use', async (t) => { if (remoteTests) { diff --git a/packages/test/src/workflows/ai-sdk.ts b/packages/test/src/workflows/ai-sdk.ts index 32bc28fbd..43840091f 100644 --- a/packages/test/src/workflows/ai-sdk.ts +++ b/packages/test/src/workflows/ai-sdk.ts @@ -113,6 +113,31 @@ export async function mcpWorkflow(prompt: string): Promise { return result.text; } +/** + * Test workflow that returns MCP tool schema structure for assertion. + * Used to verify inputSchema survives activity serialization correctly. + */ +export async function mcpSchemaTestWorkflow(): Promise<{ + toolName: string; + schemaType: string | undefined; + hasProperties: boolean; + propertyDescription: string | undefined; +}> { + const mcpClient = new TemporalMCPClient({ name: 'testServer' }); + const tools = await mcpClient.tools(); + + const [toolName, tool] = Object.entries(tools)[0]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const schema = (tool as any).inputSchema.jsonSchema; + + return { + toolName, + schemaType: schema.type, + hasProperties: schema.properties !== undefined, + propertyDescription: schema.properties?.testParam?.description, + }; +} + /** * Workflow that demonstrates embedding model support. * Uses the temporalProvider to generate embeddings for multiple text values.