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