Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,9 @@ server.registerTool(
);
```

When a tool declares an `outputSchema`, an error result (`isError: true`) may omit `structuredContent`, but whatever it does send is still validated against that schema by SDK clients. Report failures through `content` or by throwing, and keep error payloads of a different shape
out of `structuredContent`, or the client rejects the response with `-32602` instead of handing your error to the caller.

This snippet is illustrative only; for runnable servers that expose tools, see:

- [`simpleStreamableHttp.ts`](../src/examples/server/simpleStreamableHttp.ts)
Expand Down
4 changes: 3 additions & 1 deletion src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -737,7 +737,9 @@ export class Client<
);
}

// Only validate structured content if present (not when there's an error)
// Validate structured content whenever it is present, including on error
// results: an error result may omit structuredContent, but anything it does
// send is held to the tool's output schema.
if (result.structuredContent) {
try {
// Validate the structured content against the schema
Expand Down
214 changes: 214 additions & 0 deletions test/client/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2333,6 +2333,220 @@ describe('outputSchema validation', () => {
/Structured content does not match the tool's output schema/
);
});
/***
* Test: Error Result Without structuredContent Is Accepted
*
* A tool that declares an outputSchema is still allowed to fail without structured
* output: the "MUST return structuredContent" guard is skipped for error results.
*/
test('should not require structuredContent on an error result', async () => {
const server = new Server(
{
name: 'test-server',
version: '1.0.0'
},
{
capabilities: {
tools: {}
}
}
);

server.setRequestHandler(InitializeRequestSchema, async request => ({
protocolVersion: request.params.protocolVersion,
capabilities: {},
serverInfo: {
name: 'test-server',
version: '1.0.0'
}
}));

server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'failing-tool',
description: 'A tool that fails',
inputSchema: {
type: 'object',
properties: {}
},
outputSchema: {
type: 'object',
properties: {
score: { type: 'number' }
},
required: ['score'],
additionalProperties: false
}
}
]
}));

server.setRequestHandler(CallToolRequestSchema, async () => ({
isError: true,
content: [{ type: 'text', text: 'authentication required' }]
}));

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

const result = await client.callTool({ name: 'failing-tool' });
expect(result.isError).toBe(true);
expect(result.structuredContent).toBeUndefined();
});

/***
* Test: Error Result With structuredContent Is Still Validated
*
* Present-but-invalid structured content is rejected even when the tool reports
* isError, so a server must not put an error payload of its own shape in
* structuredContent. See #2748 - the server half of the SDK skips output
* validation on error results, so this combination is reachable from SDK code.
*/
test('should validate structuredContent on an error result', async () => {
const server = new Server(
{
name: 'test-server',
version: '1.0.0'
},
{
capabilities: {
tools: {}
}
}
);

server.setRequestHandler(InitializeRequestSchema, async request => ({
protocolVersion: request.params.protocolVersion,
capabilities: {},
serverInfo: {
name: 'test-server',
version: '1.0.0'
}
}));

server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'failing-tool',
description: 'A tool that fails',
inputSchema: {
type: 'object',
properties: {}
},
outputSchema: {
type: 'object',
properties: {
score: { type: 'number' }
},
required: ['score'],
additionalProperties: false
}
}
]
}));

server.setRequestHandler(CallToolRequestSchema, async () => ({
isError: true,
content: [{ type: 'text', text: '{"code":"AUTH_REQUIRED"}' }],
structuredContent: { code: 'AUTH_REQUIRED' }
}));

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

await expect(client.callTool({ name: 'failing-tool' })).rejects.toThrow(
/Structured content does not match the tool's output schema/
);
});

/***
* Test: Validation Only Happens Once a Validator Has Been Cached
*
* Validators come from tools/list, so a client that never lists tools sees no
* validation at all. This is what makes the previous case look intermittent:
* the same call throws or succeeds depending on an earlier tools/list.
*/
test('should not validate structuredContent when tools have never been listed', async () => {
const server = new Server(
{
name: 'test-server',
version: '1.0.0'
},
{
capabilities: {
tools: {}
}
}
);

server.setRequestHandler(InitializeRequestSchema, async request => ({
protocolVersion: request.params.protocolVersion,
capabilities: {},
serverInfo: {
name: 'test-server',
version: '1.0.0'
}
}));

server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'failing-tool',
description: 'A tool that fails',
inputSchema: {
type: 'object',
properties: {}
},
outputSchema: {
type: 'object',
properties: {
score: { type: 'number' }
},
required: ['score'],
additionalProperties: false
}
}
]
}));

server.setRequestHandler(CallToolRequestSchema, async () => ({
isError: true,
content: [{ type: 'text', text: '{"code":"AUTH_REQUIRED"}' }],
structuredContent: { code: 'AUTH_REQUIRED' }
}));

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)]);

// Deliberately no listTools() here, so no validator is cached
const result = await client.callTool({ name: 'failing-tool' });
expect(result.structuredContent).toEqual({ code: 'AUTH_REQUIRED' });
});
});

describe('Task-based execution', () => {
Expand Down
Loading