Skip to content
9 changes: 9 additions & 0 deletions .changeset/protocol-error-rethrow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@modelcontextprotocol/server': minor
---

Re-throw all `ProtocolError` instances from `tools/call` handler as JSON-RPC errors instead of wrapping them in `isError: true` results.

**Breaking change:** Output validation failures (missing or schema-mismatched `structuredContent`) now surface as JSON-RPC `InternalError` rejections instead of `{ isError: true }` results. Input validation failures continue to return `{ isError: true }` per the MCP spec's tool-execution-error classification.

This also means tool handlers that deliberately `throw new ProtocolError(...)` will now propagate that as a JSON-RPC error, matching the python-sdk behavior.
14 changes: 14 additions & 0 deletions docs/migration-SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,20 @@ if (error instanceof SdkError && error.code === SdkErrorCode.ClientHttpFailedToO
}
```

### Tool error classification (McpServer tools/call)

`McpServer` now re-throws any `ProtocolError` from the `tools/call` handler as a JSON-RPC error. Previously only `UrlElicitationRequired` was re-thrown; other protocol errors were wrapped as `{ isError: true }` results.

Behavior changes in `callTool` results:

- Input validation failure: `{ isError: true }` → `{ isError: true }` (unchanged)
- Output validation failure: `{ isError: true }` → throws `ProtocolError` (`InternalError`)
- Task-required without task: `{ isError: true }` → throws `ProtocolError` (`InvalidParams`)
- Handler throws `ProtocolError`: `{ isError: true }` → re-thrown as JSON-RPC error
- Handler throws plain `Error`: `{ isError: true }` → `{ isError: true }` (unchanged)

Migration: if code checks `result.isError` to detect output-schema violations or deliberate `ProtocolError` throws, add a `try/catch` around `callTool`. If a handler throws `ProtocolError` expecting tool-level wrapping, change it to throw a plain `Error`.

### OAuth error consolidation

Individual OAuth error classes replaced with single `OAuthError` class and `OAuthErrorCode` enum:
Expand Down
38 changes: 38 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,44 @@ try {
}
```

#### Tool error classification

The `tools/call` handler in `McpServer` now re-throws any `ProtocolError` as a JSON-RPC error instead of wrapping it in an `isError: true` result. Previously, only `UrlElicitationRequired` was re-thrown.

This aligns error surfaces with the MCP spec's classification:

- **Input validation failure** — unchanged, still returns `{ isError: true }` (spec classifies this as a tool-execution error)
- **Output validation failure** — now throws `ProtocolError` with `InternalError` code (was `{ isError: true }`)
- **Task-required tool called without task** — now throws `ProtocolError` with `InvalidParams` code (was `{ isError: true }`)
- **Handler throws `ProtocolError`** — now re-thrown as a JSON-RPC error (was `{ isError: true }`)
- **Handler throws plain `Error`** — unchanged, still returns `{ isError: true }`

**Before (v1):**

```typescript
const result = await client.callTool({ name: 'test', arguments: {} });
if (result.isError) {
// caught output-schema mismatches, task misconfig, handler ProtocolErrors
}
```

**After (v2):**

```typescript
try {
const result = await client.callTool({ name: 'test', arguments: {} });
if (result.isError) {
// only input validation and ordinary handler exceptions land here
}
} catch (error) {
if (error instanceof ProtocolError) {
// output validation failure, task misconfig, or handler-thrown ProtocolError
}
}
```

If your tool handler was throwing `ProtocolError` expecting it to be wrapped as `isError: true`, throw a plain `Error` instead.

#### New `SdkErrorCode` enum

The new `SdkErrorCode` enum contains string-valued codes for local SDK errors:
Expand Down
41 changes: 29 additions & 12 deletions packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export class McpServer {
// Handle taskSupport 'required' without task augmentation
if (taskSupport === 'required' && !isTaskRequest) {
throw new ProtocolError(
ProtocolErrorCode.MethodNotFound,
ProtocolErrorCode.InvalidParams,
`Tool ${request.params.name} requires task augmentation (taskSupport: 'required')`
);
}
Expand All @@ -206,8 +206,8 @@ export class McpServer {
await this.validateToolOutput(tool, result, request.params.name);
return result;
} catch (error) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This broadened catch now re-throws ProtocolError(InvalidParams) from RequestTaskStore.getTask (taskManager.ts:666-668) when a task vanishes mid-polling at L327. Before this PR, that was wrapped as {isError: true}; now it surfaces as a JSON-RPC InvalidParams error — but a task going missing during automatic polling isn't the client's fault.

Options:

  • Catch-and-convert in handleAutomaticTaskPolling before L327's getTask result is consumed
  • Or fix the underlying RequestTaskStore.getTask to throw InternalError instead of InvalidParams for task-not-found

Also: the null-check at L328-330 (if (!task) throw new ProtocolError(InternalError, ...)) is dead code — getTask throws before it can return null.

if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) {
throw error; // Return the error to the caller without wrapping in CallToolResult
if (error instanceof ProtocolError) {
throw error;
}
return this.createToolError(error instanceof Error ? error.message : String(error));
}
Expand Down Expand Up @@ -251,10 +251,9 @@ export class McpServer {

const parseResult = await validateStandardSchema(tool.inputSchema, args ?? {});
if (!parseResult.success) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Input validation error: Invalid arguments for tool ${toolName}: ${parseResult.error}`
);
// Per spec, input validation failures are tool-execution errors (isError: true),
// not protocol errors — throw plain Error so the catch wraps it as a tool result.
throw new Error(`Input validation error: Invalid arguments for tool ${toolName}: ${parseResult.error}`);
}

return parseResult.data as unknown as Args;
Expand All @@ -279,7 +278,7 @@ export class McpServer {

if (!result.structuredContent) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
ProtocolErrorCode.InternalError,
`Output validation error: Tool ${toolName} has an output schema but no structured content was provided`
);
}
Expand All @@ -288,7 +287,7 @@ export class McpServer {
const parseResult = await validateStandardSchema(tool.outputSchema, result.structuredContent);
if (!parseResult.success) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
ProtocolErrorCode.InternalError,
`Output validation error: Invalid structured content for tool ${toolName}: ${parseResult.error}`
);
}
Expand All @@ -311,7 +310,7 @@ export class McpServer {
ctx: ServerContext
): Promise<CallToolResult> {
if (!ctx.task?.store) {
throw new Error('No task store provided for task-capable tool.');
throw new ProtocolError(ProtocolErrorCode.InternalError, 'No task store provided for task-capable tool.');
}

// Validate input and create task using the executor
Expand All @@ -325,15 +324,33 @@ export class McpServer {

while (task.status !== 'completed' && task.status !== 'failed' && task.status !== 'cancelled') {
await new Promise(resolve => setTimeout(resolve, pollInterval));
const updatedTask = await ctx.task.store.getTask(taskId);
let updatedTask;
try {
updatedTask = await ctx.task.store.getTask(taskId);
} catch (error) {
// RequestTaskStore.getTask throws InvalidParams when the task is
// missing, but a task vanishing mid-poll is a server-side issue
// (the client didn't even ask for a task) — surface as InternalError.
if (error instanceof ProtocolError && error.code === ProtocolErrorCode.InvalidParams) {
throw new ProtocolError(
ProtocolErrorCode.InternalError,
`Task ${taskId} vanished during automatic polling`
);
}
throw error;
}
if (!updatedTask) {
throw new ProtocolError(ProtocolErrorCode.InternalError, `Task ${taskId} not found during polling`);
}
task = updatedTask;
}

// Return the final result
return (await ctx.task.store.getTaskResult(taskId)) as CallToolResult;
const result = (await ctx.task.store.getTaskResult(taskId)) as CallToolResult;
if (task.status === 'completed') {
await this.validateToolOutput(tool, result, request.params.name);
}
return result;
}

private _completionHandlerInitialized = false;
Expand Down
74 changes: 31 additions & 43 deletions test/integration/test/server/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1418,25 +1418,18 @@ describe('Zod v4', () => {

await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);

// Call the tool and expect it to throw an error
const result = await client.callTool({
name: 'test',
arguments: {
input: 'hello'
}
});

expect(result.isError).toBe(true);
expect(result.content).toEqual(
expect.arrayContaining([
{
type: 'text',
text: expect.stringContaining(
'Output validation error: Tool test has an output schema but no structured content was provided'
)
// Output validation failure is a server-side bug → JSON-RPC InternalError
await expect(
client.callTool({
name: 'test',
arguments: {
input: 'hello'
}
])
);
})
).rejects.toMatchObject({
code: ProtocolErrorCode.InternalError,
message: expect.stringContaining('has an output schema but no structured content')
});
});
/***
* Test: Tool with Output Schema Must Provide Structured Content
Expand Down Expand Up @@ -1550,23 +1543,18 @@ describe('Zod v4', () => {

await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);

// Call the tool and expect it to throw a server-side validation error
const result = await client.callTool({
name: 'test',
arguments: {
input: 'hello'
}
});

expect(result.isError).toBe(true);
expect(result.content).toEqual(
expect.arrayContaining([
{
type: 'text',
text: expect.stringContaining('Output validation error: Invalid structured content for tool test')
// Output validation failure is a server-side bug → JSON-RPC InternalError
await expect(
client.callTool({
name: 'test',
arguments: {
input: 'hello'
}
])
);
})
).rejects.toMatchObject({
code: ProtocolErrorCode.InternalError,
message: expect.stringMatching(/Invalid structured content for tool test/)
});
});

/***
Expand Down Expand Up @@ -6534,17 +6522,17 @@ describe('Zod v4', () => {

await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);

// Call the tool WITHOUT task augmentation - should return error
const result = await client.callTool({
name: 'long-running-task',
arguments: { input: 'test data' }
// Call the tool WITHOUT task augmentation - should throw JSON-RPC error
await expect(
client.callTool({
name: 'long-running-task',
arguments: { input: 'test data' }
})
).rejects.toMatchObject({
code: ProtocolErrorCode.InvalidParams,
message: expect.stringMatching(/requires task augmentation/)
});

// Should receive error result
expect(result.isError).toBe(true);
const content = result.content as TextContent[];
expect(content[0]!.text).toContain('requires task augmentation');

taskStore.cleanup();
});

Expand Down
Loading