Skip to content
Merged
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
54 changes: 51 additions & 3 deletions packages/core/src/shared/error-handling.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { relative } from 'path';

export enum ErrorCategory {
VALIDATION = 'VALIDATION',
INVALID_INPUT = 'INVALID_INPUT',
Expand All @@ -18,26 +20,72 @@ export class MCPError extends Error {
}
}

/**
* Sanitizes an error message to prevent information disclosure:
* - Absolute paths that start with projectRoot are replaced with relative paths.
* - All other absolute paths (Unix or Windows) are replaced with "[path redacted]".
* - Regex pattern details in Zod/validation messages are stripped.
*/
export function sanitizeErrorMessage(message: string, projectRoot: string): string {
// Replace absolute paths that start with projectRoot with relative equivalents.
// We do this first (before the blanket redaction) so project-relative paths stay readable.
let sanitized = message;

if (projectRoot) {
// Normalize projectRoot to ensure no trailing slash
const normalizedRoot = projectRoot.replace(/\/+$/, '');
// Match the projectRoot prefix (possibly followed by more path characters)
const escapedRoot = normalizedRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const projectRootRegex = new RegExp(escapedRoot + '(/[^\\s]*)?', 'g');
sanitized = sanitized.replace(projectRootRegex, (match, rest) => {
// Compute the path relative to projectRoot
const relativePath = relative(normalizedRoot, match);
return relativePath || '.';
});
}

// Replace any remaining Unix absolute paths (starting with /)
// Matches paths like /foo/bar/baz (no whitespace)
sanitized = sanitized.replace(/(?<!\w)(\/[^\s]+)/g, '[path redacted]');

// Replace Windows absolute paths (e.g. C:\foo\bar)
sanitized = sanitized.replace(/[A-Za-z]:\\[^\s]*/g, '[path redacted]');

// Strip regex pattern details from Zod validation messages.
// Patterns like: /someRegex/, "Invalid regex: /pattern/"
sanitized = sanitized.replace(/Invalid regex:[^;,\n]*/gi, 'Invalid regex: [pattern redacted]');
sanitized = sanitized.replace(/\/[^/\s]{2,}\/[gimsuy]*/g, '[pattern redacted]');

return sanitized;
}

/**
* Wraps an unknown thrown value into an MCPError with an appropriate category.
* Inference rules (applied before falling back to UNKNOWN):
* - SyntaxError / TypeError → VALIDATION
* - Error with .code ENOENT → FILESYSTEM
* - Error with .code EACCES → FILESYSTEM
*
* When projectRoot is provided, absolute paths in error messages are sanitized:
* - Paths under projectRoot become relative paths
* - All other absolute paths are replaced with "[path redacted]"
* Zod/regex pattern details in VALIDATION errors are also stripped.
*/
export function handleToolError(error: unknown): MCPError {
export function handleToolError(error: unknown, projectRoot: string = ''): MCPError {
if (error instanceof MCPError) {
return error;
}

if (error instanceof SyntaxError || error instanceof TypeError) {
return new MCPError(error.message, ErrorCategory.VALIDATION);
const message = sanitizeErrorMessage(error.message, projectRoot);
return new MCPError(message, ErrorCategory.VALIDATION);
}

if (error instanceof Error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === 'ENOENT' || nodeError.code === 'EACCES') {
return new MCPError(error.message, ErrorCategory.FILESYSTEM);
const message = sanitizeErrorMessage(error.message, projectRoot);
return new MCPError(message, ErrorCategory.FILESYSTEM);
}
return new MCPError(error.message, ErrorCategory.UNKNOWN);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/benchmark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export async function handleBenchmarkCall(

return createErrorResponse(`Unknown benchmark tool: ${name}`);
} catch (err) {
const mcpErr = handleToolError(err);
const mcpErr = handleToolError(err, config.projectRoot);
return createErrorResponse(`[${mcpErr.category}] ${mcpErr.message}`);
}
}
2 changes: 1 addition & 1 deletion packages/core/src/tools/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export async function handleBundleCall(
}
return createErrorResponse(`Unknown bundle tool: ${name}`);
} catch (err) {
const mcpErr = handleToolError(err);
const mcpErr = handleToolError(err, config.projectRoot);
return createErrorResponse(`[${mcpErr.category}] ${mcpErr.message}`);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/cdn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export async function handleCdnCall(
}
return createErrorResponse(`Unknown CDN tool: ${name}`);
} catch (err) {
const mcpErr = handleToolError(err);
const mcpErr = handleToolError(err, config.projectRoot);
return createErrorResponse(`[${mcpErr.category}] ${mcpErr.message}`);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ export async function handleComponentCall(

return createErrorResponse(`Unknown component tool: ${name}`);
} catch (err) {
const mcpErr = handleToolError(err);
const mcpErr = handleToolError(err, config.projectRoot);
return createErrorResponse(`[${mcpErr.category}] ${mcpErr.message}`);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,7 +606,7 @@ export async function handleDiscoveryCall(

return createErrorResponse(`Unknown discovery tool: ${name}`);
} catch (err) {
const mcpErr = handleToolError(err);
const mcpErr = handleToolError(err, config.projectRoot);
return createErrorResponse(`[${mcpErr.category}] ${mcpErr.message}`);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export async function handleFrameworkCall(
}
return createErrorResponse(`Unknown framework tool: ${name}`);
} catch (err) {
const mcpErr = handleToolError(err);
const mcpErr = handleToolError(err, config.projectRoot);
return createErrorResponse(`[${mcpErr.category}] ${mcpErr.message}`);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ export async function handleHealthCall(

return createErrorResponse(`Unknown health tool: ${name}`);
} catch (err) {
const mcpErr = handleToolError(err);
const mcpErr = handleToolError(err, config.projectRoot);
return createErrorResponse(`[${mcpErr.category}] ${mcpErr.message}`);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export async function handleLibraryCall(

return createErrorResponse(`Unknown library tool: ${name}`);
} catch (err) {
const mcpErr = handleToolError(err);
const mcpErr = handleToolError(err, config.projectRoot);
return createErrorResponse(`[${mcpErr.category}] ${mcpErr.message}`);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/safety.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ export async function handleSafetyCall(

return createErrorResponse(`Unknown safety tool: ${name}`);
} catch (err) {
const mcpErr = handleToolError(err);
const mcpErr = handleToolError(err, config.projectRoot);
return createErrorResponse(`[${mcpErr.category}] ${mcpErr.message}`);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ export function handleScaffoldCall(

return createErrorResponse(`Unknown scaffold tool: ${name}`);
} catch (err) {
const mcpErr = handleToolError(err);
const mcpErr = handleToolError(err, config.projectRoot);
return createErrorResponse(`[${mcpErr.category}] ${mcpErr.message}`);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export async function handleTokenCall(

return createErrorResponse(`Unknown token tool: ${name}`);
} catch (err) {
const mcpErr = handleToolError(err);
const mcpErr = handleToolError(err, config.projectRoot);
return createErrorResponse(`[${mcpErr.category}] ${mcpErr.message}`);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/typescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export function handleTypeScriptCall(

return createErrorResponse(`Unknown TypeScript tool: ${name}`);
} catch (err) {
const mcpErr = handleToolError(err);
const mcpErr = handleToolError(err, config.projectRoot);
return createErrorResponse(`[${mcpErr.category}] ${mcpErr.message}`);
}
}
Expand Down
116 changes: 116 additions & 0 deletions tests/tools/error-handling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
MCPError,
ErrorCategory,
handleToolError,
sanitizeErrorMessage,
} from '../../packages/core/src/shared/error-handling.js';

// Mock the handler modules before importing the tool dispatch functions
Expand Down Expand Up @@ -74,6 +75,121 @@ describe('handleToolError', () => {
});
});

// ─── sanitizeErrorMessage ─────────────────────────────────────────────────────

describe('sanitizeErrorMessage', () => {
describe('filesystem path sanitization', () => {
it('replaces absolute path under projectRoot with relative path', () => {
const result = sanitizeErrorMessage(
"ENOENT: no such file or directory, open '/home/jake/project/custom-elements.json'",
'/home/jake/project',
);
expect(result).toContain('custom-elements.json');
expect(result).not.toContain('/home/jake/project');
});

it('replaces absolute paths not under projectRoot with [path redacted]', () => {
const result = sanitizeErrorMessage(
"ENOENT: no such file or directory, open '/etc/passwd'",
'/home/jake/project',
);
expect(result).toContain('[path redacted]');
expect(result).not.toContain('/etc/passwd');
});

it('replaces all absolute paths when projectRoot is empty string', () => {
const result = sanitizeErrorMessage(
"Cannot read /Users/alice/myapp/tokens.json",
'',
);
expect(result).toContain('[path redacted]');
expect(result).not.toContain('/Users/alice');
});

it('leaves messages without absolute paths unchanged', () => {
const result = sanitizeErrorMessage('Token not found: --sl-color-primary', '/home/jake/project');
expect(result).toBe('Token not found: --sl-color-primary');
});
});

describe('VALIDATION / Zod pattern sanitization', () => {
it('strips regex pattern details from validation messages', () => {
const result = sanitizeErrorMessage(
"String must match pattern /^[a-z]+$/",
'/home/jake/project',
);
expect(result).not.toContain('/^[a-z]+$/');
expect(result).toContain('[pattern redacted]');
});

it('strips "Invalid regex:" detail from messages', () => {
const result = sanitizeErrorMessage(
"Invalid regex: /(?<=foo)bar/ is not valid in this engine",
'/home/jake/project',
);
expect(result).not.toContain('/(?<=foo)bar/');
expect(result).toContain('[pattern redacted]');
});

it('preserves field name context while removing regex detail', () => {
const result = sanitizeErrorMessage(
"Field 'tagName': String must match /^[a-z][a-z0-9-]*$/",
'/home/jake/project',
);
expect(result).toContain("Field 'tagName'");
expect(result).not.toContain('/^[a-z][a-z0-9-]*$/');
});
});

describe('handleToolError with projectRoot', () => {
it('sanitizes absolute path in FILESYSTEM errors when projectRoot is provided', () => {
const err = Object.assign(
new Error("ENOENT: no such file or directory, open '/Users/jake/project/custom-elements.json'"),
{ code: 'ENOENT' },
);
const result = handleToolError(err, '/Users/jake/project');
expect(result.category).toBe(ErrorCategory.FILESYSTEM);
expect(result.message).not.toContain('/Users/jake/project');
expect(result.message).toContain('custom-elements.json');
});

it('sanitizes absolute paths in VALIDATION errors when projectRoot is provided', () => {
const err = new SyntaxError("Unexpected token at /Users/jake/project/src/index.ts:10");
const result = handleToolError(err, '/Users/jake/project');
expect(result.category).toBe(ErrorCategory.VALIDATION);
expect(result.message).not.toContain('/Users/jake/project');
});

it('redacts non-project absolute paths with [path redacted]', () => {
const err = Object.assign(
new Error("ENOENT: no such file or directory, open '/tmp/scratch.txt'"),
{ code: 'ENOENT' },
);
const result = handleToolError(err, '/Users/jake/project');
expect(result.category).toBe(ErrorCategory.FILESYSTEM);
expect(result.message).toContain('[path redacted]');
expect(result.message).not.toContain('/tmp/scratch.txt');
});

it('does not sanitize messages from already-constructed MCPError', () => {
// MCPError is passed through directly without re-sanitizing
const err = new MCPError('/some/absolute/path leaked', ErrorCategory.FILESYSTEM);
const result = handleToolError(err, '/Users/jake/project');
expect(result.message).toBe('/some/absolute/path leaked');
});

it('does not include stack traces in the returned error message', () => {
const err = Object.assign(
new Error("ENOENT: no such file or directory, open '/Users/jake/project/file.json'"),
{ code: 'ENOENT' },
);
const result = handleToolError(err, '/Users/jake/project');
expect(result.message).not.toContain('at ');
expect(result.message).not.toContain('.test.ts');
});
});
});

// ─── tool dispatch ────────────────────────────────────────────────────────────

describe('tool dispatch error handling', () => {
Expand Down
Loading