feat: backwards-compatible createMessage overloads for SEP-1577 (#1212)
This commit is contained in:
parent
82041262dc
commit
cf51343cc1
6 changed files with 193 additions and 13 deletions
|
|
@ -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'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<CreateMessageResult>;
|
||||
|
||||
/**
|
||||
* 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<CreateMessageResultWithTools>;
|
||||
|
||||
/**
|
||||
* 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<CreateMessageResult | CreateMessageResultWithTools>;
|
||||
|
||||
// Implementation
|
||||
async createMessage(
|
||||
params: CreateMessageRequest['params'],
|
||||
options?: RequestOptions
|
||||
): Promise<CreateMessageResult | CreateMessageResultWithTools> {
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -80,6 +80,21 @@ type FixSpecInitializeRequest<T> = T extends { params: infer P } ? Omit<T, 'para
|
|||
|
||||
type FixSpecClientRequest<T> = T extends { params: infer P } ? Omit<T, 'params'> & { params: FixSpecInitializeRequestParams<P> } : 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> = C extends { type: 'text' | 'image' | 'audio' } ? C : never;
|
||||
type FixSpecCreateMessageResult<T> = 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<U> : NarrowToBasicContent<C>;
|
||||
}
|
||||
: T;
|
||||
|
||||
const sdkTypeChecks = {
|
||||
RequestParams: (sdk: RemovePassthrough<SDKTypes.RequestParams>, spec: SpecTypes.RequestParams) => {
|
||||
sdk = spec;
|
||||
|
|
@ -369,7 +384,10 @@ const sdkTypeChecks = {
|
|||
sdk = spec;
|
||||
spec = sdk;
|
||||
},
|
||||
CreateMessageResult: (sdk: RemovePassthrough<SDKTypes.CreateMessageResult>, spec: SpecTypes.CreateMessageResult) => {
|
||||
CreateMessageResult: (
|
||||
sdk: RemovePassthrough<SDKTypes.CreateMessageResult>,
|
||||
spec: FixSpecCreateMessageResult<SpecTypes.CreateMessageResult>
|
||||
) => {
|
||||
sdk = spec;
|
||||
spec = sdk;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
55
src/types.ts
55
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<typeof LoggingMessageNotification
|
|||
export type ToolChoice = Infer<typeof ToolChoiceSchema>;
|
||||
export type ModelHint = Infer<typeof ModelHintSchema>;
|
||||
export type ModelPreferences = Infer<typeof ModelPreferencesSchema>;
|
||||
export type SamplingContent = Infer<typeof SamplingContentSchema>;
|
||||
export type SamplingMessageContentBlock = Infer<typeof SamplingMessageContentBlockSchema>;
|
||||
export type SamplingMessage = Infer<typeof SamplingMessageSchema>;
|
||||
export type CreateMessageRequestParams = Infer<typeof CreateMessageRequestParamsSchema>;
|
||||
export type CreateMessageRequest = Infer<typeof CreateMessageRequestSchema>;
|
||||
export type CreateMessageResult = Infer<typeof CreateMessageResultSchema>;
|
||||
export type CreateMessageResultWithTools = Infer<typeof CreateMessageResultWithToolsSchema>;
|
||||
|
||||
/**
|
||||
* CreateMessageRequestParams without tools - for backwards-compatible overload.
|
||||
* Excludes tools/toolChoice to indicate they should not be provided.
|
||||
*/
|
||||
export type CreateMessageRequestParamsBase = Omit<CreateMessageRequestParams, 'tools' | 'toolChoice'>;
|
||||
|
||||
/**
|
||||
* CreateMessageRequestParams with required tools - for tool-enabled overload.
|
||||
*/
|
||||
export interface CreateMessageRequestParamsWithTools extends CreateMessageRequestParams {
|
||||
tools: Tool[];
|
||||
}
|
||||
|
||||
/* Elicitation */
|
||||
export type BooleanSchema = Infer<typeof BooleanSchemaSchema>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue