Skip to content

fix: do not expose internal error details to clients#1851

Open
lexwhiting wants to merge 1 commit intomodelcontextprotocol:mainfrom
lexwhiting:fix/sanitize-tool-errors
Open

fix: do not expose internal error details to clients#1851
lexwhiting wants to merge 1 commit intomodelcontextprotocol:mainfrom
lexwhiting:fix/sanitize-tool-errors

Conversation

@lexwhiting
Copy link
Copy Markdown

Summary

Fixes #1429. Tool handlers that throw unhandled errors now return a generic "Internal error" message instead of leaking the raw error.message to the client.

Before: Any thrown error (including stack traces, connection strings, internal hostnames) was serialized and sent to the client as a CallToolResult with isError: true.

After:

  • ToolError — a new error class for intentional client-visible messages. Throw new ToolError('Invalid input: ...') and the message is returned to the client.
  • ProtocolError — SDK-internal errors (validation, invalid params) remain visible, as they contain no sensitive information.
  • All other errors — sanitized to "Internal error". The real error is passed to an optional onToolError callback for server-side logging.

Changes

  • Add ToolError class exported from @modelcontextprotocol/server
  • Add McpServerOptions interface with optional onToolError callback
  • Update catch block in tools/call handler to sanitize unhandled errors
  • Add 2 new tests (ToolError passthrough, onToolError callback with sensitive data)
  • Update 2 existing tests to expect "Internal error" instead of raw messages

Usage

import { McpServer, ToolError } from '@modelcontextprotocol/server';

const server = new McpServer(
  { name: 'my-server', version: '1.0.0' },
  {
    onToolError: (error) => {
      console.error('Tool error:', error); // Log internally
    }
  }
);

server.registerTool('my-tool', {}, async () => {
  // This message IS shown to the client:
  throw new ToolError('Invalid input: location is required');

  // This message is NOT shown — client sees "Internal error":
  // throw new Error('postgres://admin:secret@internal-host:5432');
});

Test plan

  • All 424 existing tests pass
  • New test: ToolError messages are returned to the client
  • New test: non-ToolError exceptions return "Internal error" and trigger onToolError callback
  • Validation errors (ProtocolError) still return descriptive messages
  • UrlElicitationRequiredError still re-throws correctly
  • Pre-push hooks pass (typecheck, build, lint)

…tocol#1429)

Tool handlers that throw unhandled errors now return a generic
"Internal error" message instead of leaking the raw error.message
to the client. This prevents exposing stack traces, connection
strings, internal hostnames, or other sensitive information.

- Add ToolError class for intentional client-visible errors
- Add McpServerOptions.onToolError callback for server-side logging
- ProtocolError messages (validation, invalid params) remain visible
- Generic Error/unknown exceptions are sanitized to "Internal error"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 4, 2026

⚠️ No Changeset found

Latest commit: 1584a73

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 4, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@1851

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@1851

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@1851

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@1851

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@1851

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@1851

commit: 1584a73

Copy link
Copy Markdown

@travisbreaks travisbreaks left a comment

Choose a reason for hiding this comment

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

Solid security improvement. The three-tier model (ToolError -> ProtocolError -> generic) is clean. A few items worth considering:

1. instanceof fragility across package boundaries

error instanceof ToolError will fail if the server author's copy of the SDK is a different instance (bundled, duplicated in node_modules, or loaded via different package manager hoisting). Their intentional ToolError would silently fall through to "Internal error". A Symbol-based brand check or static ToolError.isToolError(error) method would be more robust:

const TOOL_ERROR_BRAND = Symbol.for('mcp.ToolError');

export class ToolError extends Error {
  readonly [Symbol.for('mcp.ToolError')] = true;
  
  static isToolError(error: unknown): error is ToolError {
    return typeof error === 'object' && error !== null && Symbol.for('mcp.ToolError') in error;
  }
}

Symbol.for is globally shared, so it survives package duplication.

2. ProtocolError assumed safe

The PR passes all ProtocolError messages through to clients. Worth verifying that no ProtocolError is ever constructed with user-supplied or system-internal data (file paths, connection strings) elsewhere in the SDK. If any are, those would leak through this path.

3. Silent loss without onToolError

Server authors who don't set onToolError lose all signal from internal errors. The client sees "Internal error" with no correlation ID, and the server has no log. Consider:

  • Logging to console.error by default (not just when callback is set)
  • Including a unique error ID in the client response for log correlation

4. Breaking change for existing consumers

Any code that parses tool error messages (checking for specific substrings) will break since those messages are now "Internal error". The PR description doesn't mention this. Worth calling out in the changeset (which is also missing).

5. Missing changeset

The changeset-bot flagged this. This should be at least a patch bump so downstream consumers can pin to the fix.


Tests are well-structured. The connection-string-leak test is a particularly good demonstration of the security boundary.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Do not return and expose internal errors to the client as this is a security risk

2 participants