Skip to content

Commit ac45e0a

Browse files
jssmithclaude
andcommitted
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 <noreply@anthropic.com>
1 parent c7b3316 commit ac45e0a

3 files changed

Lines changed: 207 additions & 4 deletions

File tree

packages/ai-sdk/src/activities.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import type {
55
SharedV3ProviderOptions,
66
SharedV3Headers,
77
ProviderV3,
8+
JSONSchema7,
89
} from '@ai-sdk/provider';
9-
import type { FlexibleSchema, ToolExecutionOptions } from 'ai';
10+
import type { ToolExecutionOptions } from 'ai';
1011
import { ApplicationFailure } from '@temporalio/common';
1112
import type { McpClientFactories, McpClientFactory } from './mcp';
1213

@@ -42,9 +43,15 @@ export interface InvokeEmbeddingModelArgs {
4243
*/
4344
export type InvokeEmbeddingModelResult = EmbeddingModelV3Result;
4445

46+
/**
47+
* Result from listing MCP tools, returned by the listTools activity.
48+
* Note: inputSchema is the serialized form of the AI SDK's schema wrapper.
49+
* After JSON serialization through Temporal's activity boundary, the schema
50+
* wrapper becomes { jsonSchema: JSONSchema7 }.
51+
*/
4552
export interface ListToolResult {
4653
description?: string;
47-
inputSchema: FlexibleSchema<unknown>;
54+
inputSchema: { jsonSchema: JSONSchema7 };
4855
}
4956

5057
export interface ListToolArgs {

packages/ai-sdk/src/mcp.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { jsonSchema, type ToolSet } from 'ai';
2-
import type { JSONSchema7 } from '@ai-sdk/provider';
32
import type { experimental_MCPClient as MCPClient } from '@ai-sdk/mcp';
43
import * as workflow from '@temporalio/workflow';
54
import type { ActivityOptions } from '@temporalio/workflow';
@@ -52,7 +51,8 @@ export class TemporalMCPClient {
5251
const callActivity = activities[this.options.name + '-callTool']!;
5352
return await callActivity({ name: toolName, input, options, clientArgs: this.options.clientArgs });
5453
},
55-
inputSchema: jsonSchema(toolResult.inputSchema as JSONSchema7),
54+
// toolResult.inputSchema is the serialized form { jsonSchema: JSONSchema7 }
55+
inputSchema: jsonSchema(toolResult.inputSchema.jsonSchema),
5656
type: 'dynamic',
5757
},
5858
])

packages/test/src/test-ai-sdk.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,202 @@ test('callToolActivity awaits tool.execute before closing MCP client', async (t)
544544
);
545545
});
546546

547+
test('MCP tool inputSchema survives activity serialization', async (t) => {
548+
// This test verifies that tool inputSchema is correctly reconstructed after
549+
// being serialized through Temporal's activity boundary.
550+
//
551+
// The bug: When the listTools activity returns, v.inputSchema is a schema wrapper
552+
// object with a getter. After JSON serialization (Temporal activity return), it becomes
553+
// { jsonSchema: {...} }. The workflow code then wraps this again with jsonSchema(),
554+
// creating { jsonSchema: { jsonSchema: {...} } } - double nesting that breaks the schema.
555+
//
556+
// This caused:
557+
// - "Invalid schema: type 'None'" errors from OpenAI (type was undefined)
558+
// - Missing properties in tool definitions (#1889)
559+
// - Missing property descriptions (#1889)
560+
561+
const { jsonSchema } = await import('ai');
562+
563+
// Simulate what the AI SDK's MCP client returns for a tool's inputSchema
564+
// Include property descriptions to test #1889 fix
565+
const originalSchema = jsonSchema({
566+
type: 'object',
567+
properties: {
568+
regionId: {
569+
type: 'string',
570+
description: 'The region ID to query',
571+
},
572+
limit: {
573+
type: 'number',
574+
description: 'Maximum results to return',
575+
},
576+
},
577+
required: ['regionId'],
578+
additionalProperties: false,
579+
});
580+
581+
// Simulate what the listTools activity returns (extracts inputSchema from tool)
582+
const activityResult = {
583+
testTool: {
584+
description: 'A test tool',
585+
inputSchema: originalSchema,
586+
},
587+
};
588+
589+
// Simulate JSON serialization that happens when activity returns to workflow
590+
// This is what Temporal does when passing data from activity to workflow
591+
const serialized = JSON.stringify(activityResult);
592+
const deserialized = JSON.parse(serialized);
593+
594+
// This is what the workflow receives - inputSchema is now { jsonSchema: {...} }
595+
const toolResult = deserialized.testTool;
596+
597+
// Verify the serialized structure
598+
t.truthy(toolResult.inputSchema.jsonSchema, 'After serialization, inputSchema has jsonSchema property');
599+
t.is(toolResult.inputSchema.type, undefined, 'After serialization, inputSchema.type is undefined (nested)');
600+
601+
// The fix: access toolResult.inputSchema.jsonSchema before wrapping
602+
const fixedSchema = jsonSchema(toolResult.inputSchema.jsonSchema);
603+
604+
// Verify schema type is preserved
605+
t.is(fixedSchema.jsonSchema.type, 'object', 'Schema type should be "object"');
606+
607+
// Verify properties are preserved (#1889)
608+
t.truthy(fixedSchema.jsonSchema.properties, 'Schema should have properties');
609+
t.truthy(fixedSchema.jsonSchema.properties.regionId, 'Schema should have regionId property');
610+
t.truthy(fixedSchema.jsonSchema.properties.limit, 'Schema should have limit property');
611+
612+
// Verify property descriptions are preserved (#1889)
613+
t.is(
614+
fixedSchema.jsonSchema.properties.regionId.description,
615+
'The region ID to query',
616+
'Property description should be preserved'
617+
);
618+
t.is(
619+
fixedSchema.jsonSchema.properties.limit.description,
620+
'Maximum results to return',
621+
'Property description should be preserved'
622+
);
623+
624+
// Verify required fields are preserved
625+
t.deepEqual(fixedSchema.jsonSchema.required, ['regionId'], 'Required fields should be preserved');
626+
627+
// Verify additionalProperties is preserved
628+
t.is(fixedSchema.jsonSchema.additionalProperties, false, 'additionalProperties should be preserved');
629+
630+
// Also verify what the buggy code produces (to document the bug)
631+
const buggySchema = jsonSchema(toolResult.inputSchema);
632+
t.is(buggySchema.jsonSchema.type, undefined, 'Buggy: type is undefined (double-wrapped)');
633+
t.is(buggySchema.jsonSchema.properties, undefined, 'Buggy: properties is undefined (double-wrapped)');
634+
t.truthy(buggySchema.jsonSchema.jsonSchema, 'Buggy: has nested jsonSchema showing double-wrapping');
635+
});
636+
637+
test('MCP listTools activity preserves tool and parameter metadata (#1889)', async (t) => {
638+
// This test verifies the full flow from MCP client through activity serialization,
639+
// ensuring tool descriptions and parameter metadata are preserved.
640+
// Regression test for https://github.com/temporalio/sdk-typescript/issues/1889
641+
642+
const { jsonSchema } = await import('ai');
643+
644+
// Simulate a real MCP tool with full metadata
645+
const mockMcpToolSchema = jsonSchema({
646+
type: 'object',
647+
properties: {
648+
regionId: {
649+
type: 'string',
650+
description: 'The AWS region identifier (e.g., us-east-1)',
651+
},
652+
serviceType: {
653+
type: 'string',
654+
enum: ['compute', 'storage', 'database'],
655+
description: 'Type of service to filter by',
656+
},
657+
maxResults: {
658+
type: 'integer',
659+
description: 'Maximum number of services to return',
660+
default: 10,
661+
},
662+
},
663+
required: ['regionId'],
664+
additionalProperties: false,
665+
});
666+
667+
// Create mock MCP client similar to what @ai-sdk/mcp returns
668+
const mockMcpClient = {
669+
async tools() {
670+
return {
671+
'list-services': {
672+
description: 'Lists all available services in a specified AWS region with optional filtering',
673+
inputSchema: mockMcpToolSchema,
674+
execute: async () => ({ services: [] }),
675+
},
676+
};
677+
},
678+
async close() {},
679+
};
680+
681+
// Simulate what listToolsActivity does (from activities.ts)
682+
const mcpTools = await mockMcpClient.tools();
683+
const activityResult = Object.fromEntries(
684+
Object.entries(mcpTools).map(([k, v]) => [
685+
k,
686+
{
687+
description: v.description,
688+
inputSchema: v.inputSchema,
689+
},
690+
])
691+
);
692+
693+
// Simulate Temporal activity serialization round-trip
694+
const serialized = JSON.stringify(activityResult);
695+
const workflowReceives = JSON.parse(serialized);
696+
697+
// Verify tool description is preserved
698+
t.is(
699+
workflowReceives['list-services'].description,
700+
'Lists all available services in a specified AWS region with optional filtering',
701+
'Tool description should survive serialization'
702+
);
703+
704+
// Simulate what mcp.ts does with the fix applied
705+
const toolResult = workflowReceives['list-services'];
706+
const reconstructedSchema = jsonSchema(toolResult.inputSchema.jsonSchema);
707+
708+
// Verify all schema metadata is preserved
709+
const schema = reconstructedSchema.jsonSchema;
710+
711+
t.is(schema.type, 'object', 'Schema type preserved');
712+
t.deepEqual(schema.required, ['regionId'], 'Required fields preserved');
713+
t.is(schema.additionalProperties, false, 'additionalProperties preserved');
714+
715+
// Verify all properties and their metadata
716+
t.truthy(schema.properties.regionId, 'regionId property exists');
717+
t.is(schema.properties.regionId.type, 'string', 'regionId type preserved');
718+
t.is(
719+
schema.properties.regionId.description,
720+
'The AWS region identifier (e.g., us-east-1)',
721+
'regionId description preserved'
722+
);
723+
724+
t.truthy(schema.properties.serviceType, 'serviceType property exists');
725+
t.is(schema.properties.serviceType.type, 'string', 'serviceType type preserved');
726+
t.deepEqual(schema.properties.serviceType.enum, ['compute', 'storage', 'database'], 'serviceType enum preserved');
727+
t.is(
728+
schema.properties.serviceType.description,
729+
'Type of service to filter by',
730+
'serviceType description preserved'
731+
);
732+
733+
t.truthy(schema.properties.maxResults, 'maxResults property exists');
734+
t.is(schema.properties.maxResults.type, 'integer', 'maxResults type preserved');
735+
t.is(schema.properties.maxResults.default, 10, 'maxResults default preserved');
736+
t.is(
737+
schema.properties.maxResults.description,
738+
'Maximum number of services to return',
739+
'maxResults description preserved'
740+
);
741+
});
742+
547743
// Currently fails in CI due to invalid server response but passes locally
548744
test.skip('MCP Use', async (t) => {
549745
if (remoteTests) {

0 commit comments

Comments
 (0)