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:
Basil Hosmer 2025-05-06 17:16:38 -04:00
commit 882e0b7d05
No known key found for this signature in database
6 changed files with 243 additions and 32 deletions

View file

@ -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"
});
});

View file

@ -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();
});
});

View file

@ -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;

View file

@ -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 () => {

View file

@ -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();

View file

@ -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>;