diff --git a/src/examples/server/toolWithSampleServer.ts b/src/examples/server/toolWithSampleServer.ts index c198dc0ec..e6d733598 100644 --- a/src/examples/server/toolWithSampleServer.ts +++ b/src/examples/server/toolWithSampleServer.ts @@ -33,12 +33,14 @@ mcpServer.registerTool( maxTokens: 500 }); - const contents = Array.isArray(response.content) ? response.content : [response.content]; + // Since we're not passing tools param to createMessage, response.content is single content return { - content: contents.map(content => ({ - type: 'text', - text: content.type === 'text' ? content.text : 'Unable to generate summary' - })) + content: [ + { + type: 'text', + text: response.content.type === 'text' ? response.content.text : 'Unable to generate summary' + } + ] }; } ); diff --git a/src/server/index.test.ts b/src/server/index.test.ts index 00593bf9c..c2a56420b 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -1924,6 +1924,70 @@ describe('createMessage validation', () => { }); }); +describe('createMessage backwards compatibility', () => { + test('createMessage without tools returns single content (backwards compat)', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + // Mock client returns single text content + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Hello from LLM' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Call createMessage WITHOUT tools + const result = await server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100 + }); + + // Backwards compat: result.content should be single (not array) + expect(result.model).toBe('test-model'); + expect(Array.isArray(result.content)).toBe(false); + expect(result.content.type).toBe('text'); + if (result.content.type === 'text') { + expect(result.content.text).toBe('Hello from LLM'); + } + }); + + test('createMessage with tools accepts request and returns result', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + // Mock client returns text content (tool_use schema validation is tested in types.test.ts) + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'I will use the weather tool' }, + stopReason: 'endTurn' + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Call createMessage WITH tools - verifies the overload works + const result = await server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100, + tools: [{ name: 'get_weather', inputSchema: { type: 'object' } }] + }); + + // Verify result is returned correctly + expect(result.model).toBe('test-model'); + expect(result.content.type).toBe('text'); + // With tools param, result.content can be array (CreateMessageResultWithTools type) + // This would fail type-check if we used CreateMessageResult which doesn't allow arrays + const contentArray = Array.isArray(result.content) ? result.content : [result.content]; + expect(contentArray.length).toBe(1); + }); +}); + test('should respect log level for transport with sessionId', async () => { const server = new Server( { diff --git a/src/server/index.ts b/src/server/index.ts index dfbb2a2a3..aa1a62d00 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -2,7 +2,12 @@ import { mergeCapabilities, Protocol, type NotificationOptions, type ProtocolOpt import { type ClientCapabilities, type CreateMessageRequest, + type CreateMessageResult, CreateMessageResultSchema, + type CreateMessageResultWithTools, + CreateMessageResultWithToolsSchema, + type CreateMessageRequestParamsBase, + type CreateMessageRequestParamsWithTools, type ElicitRequestFormParams, type ElicitRequestURLParams, type ElicitResult, @@ -467,7 +472,32 @@ export class Server< return this.request({ method: 'ping' }, EmptyResultSchema); } - async createMessage(params: CreateMessageRequest['params'], options?: RequestOptions) { + /** + * Request LLM sampling from the client (without tools). + * Returns single content block for backwards compatibility. + */ + async createMessage(params: CreateMessageRequestParamsBase, options?: RequestOptions): Promise; + + /** + * Request LLM sampling from the client with tool support. + * Returns content that may be a single block or array (for parallel tool calls). + */ + async createMessage(params: CreateMessageRequestParamsWithTools, options?: RequestOptions): Promise; + + /** + * Request LLM sampling from the client. + * When tools may or may not be present, returns the union type. + */ + async createMessage( + params: CreateMessageRequest['params'], + options?: RequestOptions + ): Promise; + + // Implementation + async createMessage( + params: CreateMessageRequest['params'], + options?: RequestOptions + ): Promise { // Capability check - only required when tools/toolChoice are provided if (params.tools || params.toolChoice) { if (!this._clientCapabilities?.sampling?.tools) { @@ -510,6 +540,10 @@ export class Server< } } + // Use different schemas based on whether tools are provided + if (params.tools) { + return this.request({ method: 'sampling/createMessage', params }, CreateMessageResultWithToolsSchema, options); + } return this.request({ method: 'sampling/createMessage', params }, CreateMessageResultSchema, options); } diff --git a/src/spec.types.test.ts b/src/spec.types.test.ts index 14fb039d0..66e0da207 100644 --- a/src/spec.types.test.ts +++ b/src/spec.types.test.ts @@ -80,6 +80,21 @@ type FixSpecInitializeRequest = T extends { params: infer P } ? Omit = T extends { params: infer P } ? Omit & { params: FixSpecInitializeRequestParams

} : T; +// Targeted fix: CreateMessageResult in SDK uses single content for v1.x backwards compat. +// The full array-capable type is CreateMessageResultWithTools. +// This will be aligned with schema in v2.0. +// Narrows content from SamplingMessageContentBlock (includes tool types) to basic content types only. +type NarrowToBasicContent = C extends { type: 'text' | 'image' | 'audio' } ? C : never; +type FixSpecCreateMessageResult = T extends { content: infer C; role: infer R; model: infer M } + ? { + _meta?: { [key: string]: unknown }; + model: M; + role: R; + stopReason?: string; + content: C extends (infer U)[] ? NarrowToBasicContent : NarrowToBasicContent; + } + : T; + const sdkTypeChecks = { RequestParams: (sdk: RemovePassthrough, spec: SpecTypes.RequestParams) => { sdk = spec; @@ -369,7 +384,10 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - CreateMessageResult: (sdk: RemovePassthrough, spec: SpecTypes.CreateMessageResult) => { + CreateMessageResult: ( + sdk: RemovePassthrough, + spec: FixSpecCreateMessageResult + ) => { sdk = spec; spec = sdk; }, diff --git a/src/types.test.ts b/src/types.test.ts index e6ea0b6d6..29b1857a9 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -13,6 +13,7 @@ import { SamplingMessageSchema, CreateMessageRequestSchema, CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, ClientCapabilitiesSchema } from './types.js'; @@ -787,7 +788,7 @@ describe('Types', () => { } }); - test('should validate result with tool call', () => { + test('should validate result with tool call (using WithTools schema)', () => { const result = { model: 'claude-3-5-sonnet-20241022', role: 'assistant', @@ -800,7 +801,8 @@ describe('Types', () => { stopReason: 'toolUse' }; - const parseResult = CreateMessageResultSchema.safeParse(result); + // Tool call results use CreateMessageResultWithToolsSchema + const parseResult = CreateMessageResultWithToolsSchema.safeParse(result); expect(parseResult.success).toBe(true); if (parseResult.success) { expect(parseResult.data.stopReason).toBe('toolUse'); @@ -810,9 +812,13 @@ describe('Types', () => { expect(content.type).toBe('tool_use'); } } + + // Basic CreateMessageResultSchema should NOT accept tool_use content + const basicResult = CreateMessageResultSchema.safeParse(result); + expect(basicResult.success).toBe(false); }); - test('should validate result with array content', () => { + test('should validate result with array content (using WithTools schema)', () => { const result = { model: 'claude-3-5-sonnet-20241022', role: 'assistant', @@ -828,7 +834,8 @@ describe('Types', () => { stopReason: 'toolUse' }; - const parseResult = CreateMessageResultSchema.safeParse(result); + // Array content uses CreateMessageResultWithToolsSchema + const parseResult = CreateMessageResultWithToolsSchema.safeParse(result); expect(parseResult.success).toBe(true); if (parseResult.success) { expect(parseResult.data.stopReason).toBe('toolUse'); @@ -840,6 +847,10 @@ describe('Types', () => { expect(content[1].type).toBe('tool_use'); } } + + // Basic CreateMessageResultSchema should NOT accept array content + const basicResult = CreateMessageResultSchema.safeParse(result); + expect(basicResult.success).toBe(false); }); test('should validate all new stop reasons', () => { diff --git a/src/types.ts b/src/types.ts index 03acc3e6a..7986be29d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1495,6 +1495,12 @@ export const ToolResultContentSchema = z }) .passthrough(); +/** + * Basic content types for sampling responses (without tool use). + * Used for backwards-compatible CreateMessageResult when tools are not used. + */ +export const SamplingContentSchema = z.discriminatedUnion('type', [TextContentSchema, ImageContentSchema, AudioContentSchema]); + /** * Content block types allowed in sampling messages. * This includes text, image, audio, tool use requests, and tool results. @@ -1576,9 +1582,38 @@ export const CreateMessageRequestSchema = RequestSchema.extend({ }); /** - * The client's response to a sampling/create_message request from the server. The client should inform the user before returning the sampled message, to allow them to inspect the response (human in the loop) and decide whether to allow the server to see it. + * The client's response to a sampling/create_message request from the server. + * This is the backwards-compatible version that returns single content (no arrays). + * Used when the request does not include tools. */ export const CreateMessageResultSchema = ResultSchema.extend({ + /** + * The name of the model that generated the message. + */ + model: z.string(), + /** + * The reason why sampling stopped, if known. + * + * Standard values: + * - "endTurn": Natural end of the assistant's turn + * - "stopSequence": A stop sequence was encountered + * - "maxTokens": Maximum token limit was reached + * + * This field is an open string to allow for provider-specific stop reasons. + */ + stopReason: z.optional(z.enum(['endTurn', 'stopSequence', 'maxTokens']).or(z.string())), + role: z.enum(['user', 'assistant']), + /** + * Response content. Single content block (text, image, or audio). + */ + content: SamplingContentSchema +}); + +/** + * The client's response to a sampling/create_message request when tools were provided. + * This version supports array content for tool use flows. + */ +export const CreateMessageResultWithToolsSchema = ResultSchema.extend({ /** * The name of the model that generated the message. */ @@ -1597,7 +1632,7 @@ export const CreateMessageResultSchema = ResultSchema.extend({ stopReason: z.optional(z.enum(['endTurn', 'stopSequence', 'maxTokens', 'toolUse']).or(z.string())), role: z.enum(['user', 'assistant']), /** - * Response content. May be ToolUseContent if stopReason is "toolUse". + * Response content. May be a single block or array. May include ToolUseContent if stopReason is "toolUse". */ content: z.union([SamplingMessageContentBlockSchema, z.array(SamplingMessageContentBlockSchema)]) }); @@ -2010,6 +2045,7 @@ export const ClientNotificationSchema = z.union([ export const ClientResultSchema = z.union([ EmptyResultSchema, CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, ElicitResultSchema, ListRootsResultSchema, GetTaskResultSchema, @@ -2285,11 +2321,26 @@ export type LoggingMessageNotification = Infer; export type ModelHint = Infer; export type ModelPreferences = Infer; +export type SamplingContent = Infer; export type SamplingMessageContentBlock = Infer; export type SamplingMessage = Infer; export type CreateMessageRequestParams = Infer; export type CreateMessageRequest = Infer; export type CreateMessageResult = Infer; +export type CreateMessageResultWithTools = Infer; + +/** + * CreateMessageRequestParams without tools - for backwards-compatible overload. + * Excludes tools/toolChoice to indicate they should not be provided. + */ +export type CreateMessageRequestParamsBase = Omit; + +/** + * CreateMessageRequestParams with required tools - for tool-enabled overload. + */ +export interface CreateMessageRequestParamsWithTools extends CreateMessageRequestParams { + tools: Tool[]; +} /* Elicitation */ export type BooleanSchema = Infer;