feat: update TypeScript SDK to implement draft spec changes for structured tool output
- Add outputSchema support to Tool interface with proper documentation - Split CallToolResult into structured and unstructured variants - Change structuredContent from string to object type - Add validation that tools without outputSchema cannot return structuredContent - Add validation that tools with outputSchema must return structuredContent - Update client to validate structured content as object (no JSON parsing) - Update tests to use object format for structuredContent - Add tests for new validation constraints - Update LATEST_PROTOCOL_VERSION to DRAFT-2025-v2 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
cf12fe0d83
commit
882e0b7d05
6 changed files with 243 additions and 32 deletions
|
|
@ -39,7 +39,7 @@ describe("OAuth Authorization", () => {
|
|||
const [url, options] = calls[0];
|
||||
expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
|
||||
expect(options.headers).toEqual({
|
||||
"MCP-Protocol-Version": "2025-03-26"
|
||||
"MCP-Protocol-Version": "DRAFT-2025-v2"
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -802,7 +802,7 @@ describe('outputSchema validation', () => {
|
|||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
if (request.params.name === 'test-tool') {
|
||||
return {
|
||||
structuredContent: JSON.stringify({ result: 'success', count: 42 }),
|
||||
structuredContent: { result: 'success', count: 42 },
|
||||
};
|
||||
}
|
||||
throw new Error('Unknown tool');
|
||||
|
|
@ -825,7 +825,7 @@ describe('outputSchema validation', () => {
|
|||
|
||||
// Call the tool - should validate successfully
|
||||
const result = await client.callTool({ name: 'test-tool' });
|
||||
expect(result.structuredContent).toBe('{"result":"success","count":42}');
|
||||
expect(result.structuredContent).toEqual({ result: 'success', count: 42 });
|
||||
});
|
||||
|
||||
test('should throw error when structuredContent does not match schema', async () => {
|
||||
|
|
@ -874,7 +874,7 @@ describe('outputSchema validation', () => {
|
|||
if (request.params.name === 'test-tool') {
|
||||
// Return invalid structured content (count is string instead of number)
|
||||
return {
|
||||
structuredContent: JSON.stringify({ result: 'success', count: 'not a number' }),
|
||||
structuredContent: { result: 'success', count: 'not a number' },
|
||||
};
|
||||
}
|
||||
throw new Error('Unknown tool');
|
||||
|
|
@ -1094,7 +1094,7 @@ describe('outputSchema validation', () => {
|
|||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
if (request.params.name === 'complex-tool') {
|
||||
return {
|
||||
structuredContent: JSON.stringify({
|
||||
structuredContent: {
|
||||
name: 'John Doe',
|
||||
age: 30,
|
||||
active: true,
|
||||
|
|
@ -1102,7 +1102,7 @@ describe('outputSchema validation', () => {
|
|||
metadata: {
|
||||
created: '2023-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error('Unknown tool');
|
||||
|
|
@ -1126,9 +1126,9 @@ describe('outputSchema validation', () => {
|
|||
// Call the tool - should validate successfully
|
||||
const result = await client.callTool({ name: 'complex-tool' });
|
||||
expect(result.structuredContent).toBeDefined();
|
||||
const parsedContent = JSON.parse(result.structuredContent as string);
|
||||
expect(parsedContent.name).toBe('John Doe');
|
||||
expect(parsedContent.age).toBe(30);
|
||||
const structuredContent = result.structuredContent as { name: string; age: number };
|
||||
expect(structuredContent.name).toBe('John Doe');
|
||||
expect(structuredContent.age).toBe(30);
|
||||
});
|
||||
|
||||
test('should fail validation with additional properties when not allowed', async () => {
|
||||
|
|
@ -1176,10 +1176,10 @@ describe('outputSchema validation', () => {
|
|||
if (request.params.name === 'strict-tool') {
|
||||
// Return structured content with extra property
|
||||
return {
|
||||
structuredContent: JSON.stringify({
|
||||
structuredContent: {
|
||||
name: 'John',
|
||||
extraField: 'not allowed',
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error('Unknown tool');
|
||||
|
|
@ -1205,4 +1205,137 @@ describe('outputSchema validation', () => {
|
|||
/Structured content does not match the tool's output schema/
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw error when tool without outputSchema returns structuredContent', async () => {
|
||||
const server = new Server({
|
||||
name: 'test-server',
|
||||
version: '1.0.0',
|
||||
}, {
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
});
|
||||
|
||||
// Set up server handlers
|
||||
server.setRequestHandler(InitializeRequestSchema, async (request) => ({
|
||||
protocolVersion: request.params.protocolVersion,
|
||||
capabilities: {},
|
||||
serverInfo: {
|
||||
name: 'test-server',
|
||||
version: '1.0.0',
|
||||
}
|
||||
}));
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [
|
||||
{
|
||||
name: 'test-tool',
|
||||
description: 'A test tool',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
// No outputSchema defined
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
if (request.params.name === 'test-tool') {
|
||||
// Incorrectly return structuredContent for a tool without outputSchema
|
||||
return {
|
||||
structuredContent: { result: 'This should not be allowed' },
|
||||
};
|
||||
}
|
||||
throw new Error('Unknown tool');
|
||||
});
|
||||
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([
|
||||
client.connect(clientTransport),
|
||||
server.connect(serverTransport),
|
||||
]);
|
||||
|
||||
// List tools to cache the schemas
|
||||
await client.listTools();
|
||||
|
||||
// Call the tool - should throw validation error
|
||||
await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow(
|
||||
/Tool without outputSchema cannot return structuredContent/
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw error when structuredContent is not an object', async () => {
|
||||
const server = new Server({
|
||||
name: 'test-server',
|
||||
version: '1.0.0',
|
||||
}, {
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
});
|
||||
|
||||
// Set up server handlers
|
||||
server.setRequestHandler(InitializeRequestSchema, async (request) => ({
|
||||
protocolVersion: request.params.protocolVersion,
|
||||
capabilities: {},
|
||||
serverInfo: {
|
||||
name: 'test-server',
|
||||
version: '1.0.0',
|
||||
}
|
||||
}));
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [
|
||||
{
|
||||
name: 'test-tool',
|
||||
description: 'A test tool',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
result: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
if (request.params.name === 'test-tool') {
|
||||
// Try to return a non-object value as structuredContent
|
||||
return {
|
||||
structuredContent: "This should be an object, not a string" as any,
|
||||
};
|
||||
}
|
||||
throw new Error('Unknown tool');
|
||||
});
|
||||
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([
|
||||
client.connect(clientTransport),
|
||||
server.connect(serverTransport),
|
||||
]);
|
||||
|
||||
// List tools to cache the schemas
|
||||
await client.listTools();
|
||||
|
||||
// Call the tool - should throw validation error
|
||||
await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -438,11 +438,8 @@ export class Client<
|
|||
}
|
||||
|
||||
try {
|
||||
// Parse the structured content as JSON
|
||||
const contentData = JSON.parse(result.structuredContent as string);
|
||||
|
||||
// Validate the content against the schema
|
||||
const validationResult = outputSchema.safeParse(contentData);
|
||||
// Validate the structured content (which is already an object) against the schema
|
||||
const validationResult = outputSchema.safeParse(result.structuredContent);
|
||||
|
||||
if (!validationResult.success) {
|
||||
throw new McpError(
|
||||
|
|
@ -459,6 +456,14 @@ export class Client<
|
|||
`Failed to validate structured content: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// If tool doesn't have outputSchema, it MUST NOT return structuredContent
|
||||
if (result.structuredContent) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidRequest,
|
||||
`Tool without outputSchema cannot return structuredContent`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -795,11 +795,11 @@ describe("tool()", () => {
|
|||
},
|
||||
async ({ input }) => ({
|
||||
// When outputSchema is defined, return structuredContent instead of content
|
||||
structuredContent: JSON.stringify({
|
||||
structuredContent: {
|
||||
processedInput: input,
|
||||
resultType: "structured",
|
||||
timestamp: "2023-01-01T00:00:00Z"
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -860,10 +860,14 @@ describe("tool()", () => {
|
|||
expect(result.structuredContent).toBeDefined();
|
||||
expect(result.content).toBeUndefined(); // Should not have content when structuredContent is used
|
||||
|
||||
const parsed = JSON.parse(result.structuredContent || "{}");
|
||||
expect(parsed.processedInput).toBe("hello");
|
||||
expect(parsed.resultType).toBe("structured");
|
||||
expect(parsed.timestamp).toBe("2023-01-01T00:00:00Z");
|
||||
const structuredContent = result.structuredContent as {
|
||||
processedInput: string;
|
||||
resultType: string;
|
||||
timestamp: string;
|
||||
};
|
||||
expect(structuredContent.processedInput).toBe("hello");
|
||||
expect(structuredContent.resultType).toBe("structured");
|
||||
expect(structuredContent.timestamp).toBe("2023-01-01T00:00:00Z");
|
||||
});
|
||||
|
||||
test("should pass sessionId to tool callback via RequestHandlerExtra", async () => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ describe("Types", () => {
|
|||
|
||||
test("should have correct latest protocol version", () => {
|
||||
expect(LATEST_PROTOCOL_VERSION).toBeDefined();
|
||||
expect(LATEST_PROTOCOL_VERSION).toBe("2025-03-26");
|
||||
expect(LATEST_PROTOCOL_VERSION).toBe("DRAFT-2025-v2");
|
||||
});
|
||||
test("should have correct supported protocol versions", () => {
|
||||
expect(SUPPORTED_PROTOCOL_VERSIONS).toBeDefined();
|
||||
|
|
|
|||
87
src/types.ts
87
src/types.ts
|
|
@ -1,8 +1,9 @@
|
|||
import { z, ZodTypeAny } from "zod";
|
||||
|
||||
export const LATEST_PROTOCOL_VERSION = "2025-03-26";
|
||||
export const LATEST_PROTOCOL_VERSION = "DRAFT-2025-v2";
|
||||
export const SUPPORTED_PROTOCOL_VERSIONS = [
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
"2025-03-26",
|
||||
"2024-11-05",
|
||||
"2024-10-07",
|
||||
];
|
||||
|
|
@ -829,12 +830,16 @@ export const ToolSchema = z
|
|||
.object({
|
||||
type: z.literal("object"),
|
||||
properties: z.optional(z.object({}).passthrough()),
|
||||
required: z.optional(z.array(z.string())),
|
||||
})
|
||||
.passthrough(),
|
||||
/**
|
||||
* A JSON Schema object defining the expected output for the tool.
|
||||
* An optional JSON Schema object defining the structure of the tool's output.
|
||||
*
|
||||
* If set, a CallToolResult for this Tool MUST contain a structuredContent field whose contents validate against this schema.
|
||||
* If not set, a CallToolResult for this Tool MUST NOT contain a structuredContent field and MUST contain a content field.
|
||||
*/
|
||||
outputSchema: z.object({type: z.any()}).passthrough().optional(),
|
||||
outputSchema: z.optional(z.object({}).passthrough()),
|
||||
/**
|
||||
* Optional additional tool information.
|
||||
*/
|
||||
|
|
@ -858,15 +863,76 @@ export const ListToolsResultSchema = PaginatedResultSchema.extend({
|
|||
|
||||
/**
|
||||
* The server's response to a tool call.
|
||||
*
|
||||
* Any errors that originate from the tool SHOULD be reported inside the result
|
||||
* object, with `isError` set to true, _not_ as an MCP protocol-level error
|
||||
* response. Otherwise, the LLM would not be able to see that an error occurred
|
||||
* and self-correct.
|
||||
*
|
||||
* However, any errors in _finding_ the tool, an error indicating that the
|
||||
* server does not support tool calls, or any other exceptional conditions,
|
||||
* should be reported as an MCP error response.
|
||||
*/
|
||||
export const CallToolResultSchema = ResultSchema.extend({
|
||||
content: z.array(
|
||||
z.union([TextContentSchema, ImageContentSchema, AudioContentSchema, EmbeddedResourceSchema]),
|
||||
).optional(),
|
||||
structuredContent: z.string().optional(),
|
||||
isError: z.boolean().default(false).optional(),
|
||||
export const ContentListSchema = z.array(
|
||||
z.union([
|
||||
TextContentSchema,
|
||||
ImageContentSchema,
|
||||
AudioContentSchema,
|
||||
EmbeddedResourceSchema,
|
||||
]),
|
||||
);
|
||||
|
||||
export const CallToolUnstructuredResultSchema = ResultSchema.extend({
|
||||
/**
|
||||
* A list of content objects that represent the result of the tool call.
|
||||
*
|
||||
* If the Tool does not define an outputSchema, this field MUST be present in the result.
|
||||
*/
|
||||
content: ContentListSchema,
|
||||
|
||||
/**
|
||||
* Structured output must not be provided in an unstructured tool result.
|
||||
*/
|
||||
structuredContent: z.never().optional(),
|
||||
|
||||
/**
|
||||
* Whether the tool call ended in an error.
|
||||
*
|
||||
* If not set, this is assumed to be false (the call was successful).
|
||||
*/
|
||||
isError: z.optional(z.boolean()),
|
||||
});
|
||||
|
||||
export const CallToolStructuredResultSchema = ResultSchema.extend({
|
||||
/**
|
||||
* An object containing structured tool output.
|
||||
*
|
||||
* If the Tool defines an outputSchema, this field MUST be present in the result, and contain a JSON object that matches the schema.
|
||||
*/
|
||||
structuredContent: z.object({}).passthrough(),
|
||||
|
||||
/**
|
||||
* A list of content objects that represent the result of the tool call.
|
||||
*
|
||||
* If the Tool defines an outputSchema, this field MAY be present in the result.
|
||||
* Tools may use this field to provide compatibility with older clients that do not support structured content.
|
||||
* Clients that support structured content should ignore this field.
|
||||
*/
|
||||
content: z.optional(ContentListSchema),
|
||||
|
||||
/**
|
||||
* Whether the tool call ended in an error.
|
||||
*
|
||||
* If not set, this is assumed to be false (the call was successful).
|
||||
*/
|
||||
isError: z.optional(z.boolean()),
|
||||
});
|
||||
|
||||
export const CallToolResultSchema = z.union([
|
||||
CallToolUnstructuredResultSchema,
|
||||
CallToolStructuredResultSchema,
|
||||
]);
|
||||
|
||||
/**
|
||||
* CallToolResultSchema extended with backwards compatibility to protocol version 2024-10-07.
|
||||
*/
|
||||
|
|
@ -1317,6 +1383,9 @@ export type ToolAnnotations = Infer<typeof ToolAnnotationsSchema>;
|
|||
export type Tool = Infer<typeof ToolSchema>;
|
||||
export type ListToolsRequest = Infer<typeof ListToolsRequestSchema>;
|
||||
export type ListToolsResult = Infer<typeof ListToolsResultSchema>;
|
||||
export type ContentList = Infer<typeof ContentListSchema>;
|
||||
export type CallToolUnstructuredResult = Infer<typeof CallToolUnstructuredResultSchema>;
|
||||
export type CallToolStructuredResult = Infer<typeof CallToolStructuredResultSchema>;
|
||||
export type CallToolResult = Infer<typeof CallToolResultSchema>;
|
||||
export type CompatibilityCallToolResult = Infer<typeof CompatibilityCallToolResultSchema>;
|
||||
export type CallToolRequest = Infer<typeof CallToolRequestSchema>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue