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: 2 additions & 1 deletion packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ export type {
ResourceMetadata,
ToolCallback
} from './server/mcp.js';
export { McpServer, ResourceTemplate } from './server/mcp.js';
export { McpServer, ResourceTemplate, ToolError } from './server/mcp.js';
export type { McpServerOptions } from './server/mcp.js';
export type { HostHeaderValidationResult } from './server/middleware/hostHeaderValidation.js';
export { hostHeaderValidationResponse, localhostAllowedHostnames, validateHostHeader } from './server/middleware/hostHeaderValidation.js';
export type { ServerOptions } from './server/server.js';
Expand Down
55 changes: 52 additions & 3 deletions packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,39 @@ import { getCompleter, isCompletable } from './completable.js';
import type { ServerOptions } from './server.js';
import { Server } from './server.js';

/**
* An error that tool authors can throw to return a client-visible error message.
*
* When a tool handler throws a `ToolError`, the error message is returned to the
* client as a `CallToolResult` with `isError: true`. All other thrown errors are
* treated as internal errors and their messages are not exposed to the client.
*
* @example
* ```ts
* server.registerTool('my-tool', {}, async () => {
* throw new ToolError('Invalid input: location is required');
* });
* ```
*/
export class ToolError extends Error {
constructor(message: string) {
super(message);
this.name = 'ToolError';
}
}

/**
* Options for creating an {@linkcode McpServer}.
*/
export interface McpServerOptions extends ServerOptions {
/**
* Optional callback invoked when a tool handler throws an error that is not
* a {@linkcode ToolError}. Use this to log internal errors without exposing
* them to the client.
*/
onToolError?: (error: unknown) => void;
}

/**
* High-level MCP server that provides a simpler API for working with resources, tools, and prompts.
* For advanced usage (like sending notifications or setting custom request handlers), use the underlying
Expand All @@ -71,9 +104,12 @@ export class McpServer {
private _registeredTools: { [name: string]: RegisteredTool } = {};
private _registeredPrompts: { [name: string]: RegisteredPrompt } = {};
private _experimental?: { tasks: ExperimentalMcpServerTasks };
private _onToolError?: (error: unknown) => void;

constructor(serverInfo: Implementation, options?: ServerOptions) {
this.server = new Server(serverInfo, options);
constructor(serverInfo: Implementation, options?: McpServerOptions) {
const { onToolError, ...serverOptions } = options ?? {};
this.server = new Server(serverInfo, serverOptions);
this._onToolError = onToolError;
}

/**
Expand Down Expand Up @@ -209,7 +245,20 @@ export class McpServer {
if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) {
throw error; // Return the error to the caller without wrapping in CallToolResult
}
return this.createToolError(error instanceof Error ? error.message : String(error));
if (error instanceof ToolError) {
// Developer intentionally wants this message shown to the client
return this.createToolError(error.message);
}
if (error instanceof ProtocolError) {
// SDK-internal errors (validation, invalid params) are safe to expose
return this.createToolError(error.message);
}
// SECURITY: Do not expose internal error details to the client.
// Use ToolError for intentional client-visible errors.
if (this._onToolError) {
this._onToolError(error);
}
return this.createToolError('Internal error');
}
});

Expand Down
93 changes: 89 additions & 4 deletions test/integration/test/server/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
UriTemplate,
UrlElicitationRequiredError
} from '@modelcontextprotocol/core';
import { completable, McpServer, ResourceTemplate } from '@modelcontextprotocol/server';
import { completable, McpServer, ResourceTemplate, ToolError } from '@modelcontextprotocol/server';
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import * as z from 'zod/v4';

Expand Down Expand Up @@ -1799,11 +1799,95 @@ describe('Zod v4', () => {
expect(result.content).toEqual([
{
type: 'text',
text: 'Tool execution failed'
text: 'Internal error'
}
]);
});

test('should expose ToolError messages to the client', async () => {
const mcpServer = new McpServer({
name: 'test server',
version: '1.0'
});

const client = new Client({
name: 'test client',
version: '1.0'
});

mcpServer.registerTool('tool-error-test', {}, async () => {
throw new ToolError('Invalid input: location is required');
});

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

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

const result = await client.request({
method: 'tools/call',
params: {
name: 'tool-error-test'
}
});

expect(result.isError).toBe(true);
expect(result.content).toEqual([
{
type: 'text',
text: 'Invalid input: location is required'
}
]);
});

test('should call onToolError callback for non-ToolError exceptions', async () => {
let capturedError: unknown = null;

const mcpServer = new McpServer(
{
name: 'test server',
version: '1.0'
},
{
onToolError: error => {
capturedError = error;
}
}
);

const client = new Client({
name: 'test client',
version: '1.0'
});

mcpServer.registerTool('internal-error-test', {}, async () => {
throw new Error('Database connection string: postgres://admin:secret@internal-host:5432');
});

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

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

const result = await client.request({
method: 'tools/call',
params: {
name: 'internal-error-test'
}
});

// Client should NOT see the internal error details
expect(result.isError).toBe(true);
expect(result.content).toEqual([
{
type: 'text',
text: 'Internal error'
}
]);

// But the onToolError callback should have received the real error
expect(capturedError).toBeInstanceOf(Error);
expect((capturedError as Error).message).toBe('Database connection string: postgres://admin:secret@internal-host:5432');
});

/***
* Test: ProtocolError for Invalid Tool Name
*/
Expand Down Expand Up @@ -6970,9 +7054,10 @@ describe('Zod v4', () => {
arguments: {}
});

// Should receive an error since cancelled tasks don't have results
// Should receive an error since cancelled tasks don't have results.
// Internal error details are not exposed to the client.
expect(result).toHaveProperty('content');
expect(result.content).toEqual([{ type: 'text' as const, text: expect.stringContaining('has no result stored') }]);
expect(result.content).toEqual([{ type: 'text' as const, text: 'Internal error' }]);

// Wait for async operations to complete
await waitForLatch();
Expand Down
Loading