Skip to content

Commit f33eb09

Browse files
bhosmer-antclaude
andcommitted
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 <[email protected]>
1 parent bdefb9b commit f33eb09

File tree

6 files changed

+243
-32
lines changed

6 files changed

+243
-32
lines changed

src/client/auth.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ describe("OAuth Authorization", () => {
3939
const [url, options] = calls[0];
4040
expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
4141
expect(options.headers).toEqual({
42-
"MCP-Protocol-Version": "2025-03-26"
42+
"MCP-Protocol-Version": "DRAFT-2025-v2"
4343
});
4444
});
4545

src/client/index.test.ts

Lines changed: 143 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -802,7 +802,7 @@ describe('outputSchema validation', () => {
802802
server.setRequestHandler(CallToolRequestSchema, async (request) => {
803803
if (request.params.name === 'test-tool') {
804804
return {
805-
structuredContent: JSON.stringify({ result: 'success', count: 42 }),
805+
structuredContent: { result: 'success', count: 42 },
806806
};
807807
}
808808
throw new Error('Unknown tool');
@@ -825,7 +825,7 @@ describe('outputSchema validation', () => {
825825

826826
// Call the tool - should validate successfully
827827
const result = await client.callTool({ name: 'test-tool' });
828-
expect(result.structuredContent).toBe('{"result":"success","count":42}');
828+
expect(result.structuredContent).toEqual({ result: 'success', count: 42 });
829829
});
830830

831831
test('should throw error when structuredContent does not match schema', async () => {
@@ -874,7 +874,7 @@ describe('outputSchema validation', () => {
874874
if (request.params.name === 'test-tool') {
875875
// Return invalid structured content (count is string instead of number)
876876
return {
877-
structuredContent: JSON.stringify({ result: 'success', count: 'not a number' }),
877+
structuredContent: { result: 'success', count: 'not a number' },
878878
};
879879
}
880880
throw new Error('Unknown tool');
@@ -1094,15 +1094,15 @@ describe('outputSchema validation', () => {
10941094
server.setRequestHandler(CallToolRequestSchema, async (request) => {
10951095
if (request.params.name === 'complex-tool') {
10961096
return {
1097-
structuredContent: JSON.stringify({
1097+
structuredContent: {
10981098
name: 'John Doe',
10991099
age: 30,
11001100
active: true,
11011101
tags: ['user', 'admin'],
11021102
metadata: {
11031103
created: '2023-01-01T00:00:00Z',
11041104
},
1105-
}),
1105+
},
11061106
};
11071107
}
11081108
throw new Error('Unknown tool');
@@ -1126,9 +1126,9 @@ describe('outputSchema validation', () => {
11261126
// Call the tool - should validate successfully
11271127
const result = await client.callTool({ name: 'complex-tool' });
11281128
expect(result.structuredContent).toBeDefined();
1129-
const parsedContent = JSON.parse(result.structuredContent as string);
1130-
expect(parsedContent.name).toBe('John Doe');
1131-
expect(parsedContent.age).toBe(30);
1129+
const structuredContent = result.structuredContent as { name: string; age: number };
1130+
expect(structuredContent.name).toBe('John Doe');
1131+
expect(structuredContent.age).toBe(30);
11321132
});
11331133

11341134
test('should fail validation with additional properties when not allowed', async () => {
@@ -1176,10 +1176,10 @@ describe('outputSchema validation', () => {
11761176
if (request.params.name === 'strict-tool') {
11771177
// Return structured content with extra property
11781178
return {
1179-
structuredContent: JSON.stringify({
1179+
structuredContent: {
11801180
name: 'John',
11811181
extraField: 'not allowed',
1182-
}),
1182+
},
11831183
};
11841184
}
11851185
throw new Error('Unknown tool');
@@ -1205,4 +1205,137 @@ describe('outputSchema validation', () => {
12051205
/Structured content does not match the tool's output schema/
12061206
);
12071207
});
1208+
1209+
test('should throw error when tool without outputSchema returns structuredContent', async () => {
1210+
const server = new Server({
1211+
name: 'test-server',
1212+
version: '1.0.0',
1213+
}, {
1214+
capabilities: {
1215+
tools: {},
1216+
},
1217+
});
1218+
1219+
// Set up server handlers
1220+
server.setRequestHandler(InitializeRequestSchema, async (request) => ({
1221+
protocolVersion: request.params.protocolVersion,
1222+
capabilities: {},
1223+
serverInfo: {
1224+
name: 'test-server',
1225+
version: '1.0.0',
1226+
}
1227+
}));
1228+
1229+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
1230+
tools: [
1231+
{
1232+
name: 'test-tool',
1233+
description: 'A test tool',
1234+
inputSchema: {
1235+
type: 'object',
1236+
properties: {},
1237+
},
1238+
// No outputSchema defined
1239+
},
1240+
],
1241+
}));
1242+
1243+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
1244+
if (request.params.name === 'test-tool') {
1245+
// Incorrectly return structuredContent for a tool without outputSchema
1246+
return {
1247+
structuredContent: { result: 'This should not be allowed' },
1248+
};
1249+
}
1250+
throw new Error('Unknown tool');
1251+
});
1252+
1253+
const client = new Client({
1254+
name: 'test-client',
1255+
version: '1.0.0',
1256+
});
1257+
1258+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
1259+
1260+
await Promise.all([
1261+
client.connect(clientTransport),
1262+
server.connect(serverTransport),
1263+
]);
1264+
1265+
// List tools to cache the schemas
1266+
await client.listTools();
1267+
1268+
// Call the tool - should throw validation error
1269+
await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow(
1270+
/Tool without outputSchema cannot return structuredContent/
1271+
);
1272+
});
1273+
1274+
test('should throw error when structuredContent is not an object', async () => {
1275+
const server = new Server({
1276+
name: 'test-server',
1277+
version: '1.0.0',
1278+
}, {
1279+
capabilities: {
1280+
tools: {},
1281+
},
1282+
});
1283+
1284+
// Set up server handlers
1285+
server.setRequestHandler(InitializeRequestSchema, async (request) => ({
1286+
protocolVersion: request.params.protocolVersion,
1287+
capabilities: {},
1288+
serverInfo: {
1289+
name: 'test-server',
1290+
version: '1.0.0',
1291+
}
1292+
}));
1293+
1294+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
1295+
tools: [
1296+
{
1297+
name: 'test-tool',
1298+
description: 'A test tool',
1299+
inputSchema: {
1300+
type: 'object',
1301+
properties: {},
1302+
},
1303+
outputSchema: {
1304+
type: 'object',
1305+
properties: {
1306+
result: { type: 'string' },
1307+
},
1308+
},
1309+
},
1310+
],
1311+
}));
1312+
1313+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
1314+
if (request.params.name === 'test-tool') {
1315+
// Try to return a non-object value as structuredContent
1316+
return {
1317+
structuredContent: "This should be an object, not a string" as any,
1318+
};
1319+
}
1320+
throw new Error('Unknown tool');
1321+
});
1322+
1323+
const client = new Client({
1324+
name: 'test-client',
1325+
version: '1.0.0',
1326+
});
1327+
1328+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
1329+
1330+
await Promise.all([
1331+
client.connect(clientTransport),
1332+
server.connect(serverTransport),
1333+
]);
1334+
1335+
// List tools to cache the schemas
1336+
await client.listTools();
1337+
1338+
// Call the tool - should throw validation error
1339+
await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow();
1340+
});
12081341
});

src/client/index.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -438,11 +438,8 @@ export class Client<
438438
}
439439

440440
try {
441-
// Parse the structured content as JSON
442-
const contentData = JSON.parse(result.structuredContent as string);
443-
444-
// Validate the content against the schema
445-
const validationResult = outputSchema.safeParse(contentData);
441+
// Validate the structured content (which is already an object) against the schema
442+
const validationResult = outputSchema.safeParse(result.structuredContent);
446443

447444
if (!validationResult.success) {
448445
throw new McpError(
@@ -459,6 +456,14 @@ export class Client<
459456
`Failed to validate structured content: ${error instanceof Error ? error.message : String(error)}`
460457
);
461458
}
459+
} else {
460+
// If tool doesn't have outputSchema, it MUST NOT return structuredContent
461+
if (result.structuredContent) {
462+
throw new McpError(
463+
ErrorCode.InvalidRequest,
464+
`Tool without outputSchema cannot return structuredContent`
465+
);
466+
}
462467
}
463468

464469
return result;

src/server/mcp.test.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -748,11 +748,11 @@ describe("tool()", () => {
748748
},
749749
async ({ input }) => ({
750750
// When outputSchema is defined, return structuredContent instead of content
751-
structuredContent: JSON.stringify({
751+
structuredContent: {
752752
processedInput: input,
753753
resultType: "structured",
754754
timestamp: "2023-01-01T00:00:00Z"
755-
}),
755+
},
756756
}),
757757
);
758758

@@ -813,10 +813,14 @@ describe("tool()", () => {
813813
expect(result.structuredContent).toBeDefined();
814814
expect(result.content).toBeUndefined(); // Should not have content when structuredContent is used
815815

816-
const parsed = JSON.parse(result.structuredContent || "{}");
817-
expect(parsed.processedInput).toBe("hello");
818-
expect(parsed.resultType).toBe("structured");
819-
expect(parsed.timestamp).toBe("2023-01-01T00:00:00Z");
816+
const structuredContent = result.structuredContent as {
817+
processedInput: string;
818+
resultType: string;
819+
timestamp: string;
820+
};
821+
expect(structuredContent.processedInput).toBe("hello");
822+
expect(structuredContent.resultType).toBe("structured");
823+
expect(structuredContent.timestamp).toBe("2023-01-01T00:00:00Z");
820824
});
821825

822826
test("should pass sessionId to tool callback via RequestHandlerExtra", async () => {

src/types.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ describe("Types", () => {
44

55
test("should have correct latest protocol version", () => {
66
expect(LATEST_PROTOCOL_VERSION).toBeDefined();
7-
expect(LATEST_PROTOCOL_VERSION).toBe("2025-03-26");
7+
expect(LATEST_PROTOCOL_VERSION).toBe("DRAFT-2025-v2");
88
});
99
test("should have correct supported protocol versions", () => {
1010
expect(SUPPORTED_PROTOCOL_VERSIONS).toBeDefined();

0 commit comments

Comments
 (0)