From 5eb6d546f2e15e469858947d304a7391fee1c8be Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 2 Dec 2025 11:12:53 +0000 Subject: [PATCH 1/3] feat: backwards-compatible createMessage overloads for SEP-1577 Introduce method overloading for createMessage to preserve backwards compatibility while supporting the new tools feature from SEP-1577. When called without tools, createMessage returns CreateMessageResult with single content (backwards compatible). When called with tools, it returns CreateMessageResultWithTools which allows array content. This allows existing code that doesn't use tools to continue working without any changes, while new code using tools gets the appropriate type that handles array content. Changes: - Add SamplingContentSchema for basic content types (no tool use) - Add CreateMessageResultWithToolsSchema for tool-enabled responses - Add CreateMessageRequestParamsBase/WithTools types for overloads - Add method overloads to Server.createMessage() - Update tests to use appropriate schemas - Simplify example that doesn't use tools --- src/examples/server/toolWithSampleServer.ts | 12 +++-- src/server/index.ts | 36 +++++++++++++- src/types.test.ts | 19 +++++-- src/types.ts | 55 ++++++++++++++++++++- 4 files changed, 110 insertions(+), 12 deletions(-) diff --git a/src/examples/server/toolWithSampleServer.ts b/src/examples/server/toolWithSampleServer.ts index c198dc0ec..3d8d8f4ec 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 using tools, response.content is guaranteed to be 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.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/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 e02d4e23e..84047d0df 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)]) }); @@ -2005,6 +2040,7 @@ export const ClientNotificationSchema = z.union([ export const ClientResultSchema = z.union([ EmptyResultSchema, CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, ElicitResultSchema, ListRootsResultSchema, GetTaskResultSchema, @@ -2274,11 +2310,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; From 102f4a177c1947d6f3608c06e17be5a6fe97308c Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 2 Dec 2025 11:26:32 +0000 Subject: [PATCH 2/3] docs: add FixSpec wrapper for CreateMessageResult schema divergence Document the intentional divergence between SDK's CreateMessageResult (single content for v1.x backwards compat) and the spec's version (array content per SEP-1577). The full array-capable type is available as CreateMessageResultWithTools. This will be aligned with schema in v2.0. --- src/examples/server/toolWithSampleServer.ts | 2 +- src/spec.types.test.ts | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/examples/server/toolWithSampleServer.ts b/src/examples/server/toolWithSampleServer.ts index 3d8d8f4ec..e6d733598 100644 --- a/src/examples/server/toolWithSampleServer.ts +++ b/src/examples/server/toolWithSampleServer.ts @@ -33,7 +33,7 @@ mcpServer.registerTool( maxTokens: 500 }); - // Since we're not using tools, response.content is guaranteed to be single content + // Since we're not passing tools param to createMessage, response.content is single content return { content: [ { 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; }, From 5dca8f37b08f31396901d8fca145a49781094b9e Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 2 Dec 2025 13:15:44 +0000 Subject: [PATCH 3/3] test: add runtime backwards compatibility tests for createMessage --- src/server/index.test.ts | 64 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) 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( {